70 |
71 | Legitimate Reasons for Processing Your Personal Information
72 |
73 |
74 | We only collect and use your personal information when we have a
75 | legitimate reason for doing so. In which instance, we only collect
76 | personal information that is reasonably necessary to provide our
77 | services to you.
78 |
79 |
80 | Collection and Use of Information
81 |
82 |
83 | We may collect personal information from you when you do any of the
84 | following on our website:
85 |
86 |
87 |
88 | Sign up to receive updates from us via email or social media channels
89 |
90 |
Use a mobile device or web browser to access our content
91 |
92 | Contact us via email, social media, or on any similar technologies
93 |
94 |
When you mention us on social media
95 |
96 |
97 | We may collect, hold, use, and disclose information for the following
98 | purposes, and personal information will not be further processed in a
99 | manner that is incompatible with these purposes:
100 |
101 |
102 | We may collect, hold, use, and disclose information for the following
103 | purposes, and personal information will not be further processed in a
104 | manner that is incompatible with these purposes:
105 |
106 |
107 |
108 | to enable you to customize or personalize your experience of our
109 | website
110 |
111 |
to contact and communicate with you
112 |
113 | for analytics, market research, and business development, including to
114 | operate and improve our website, associated applications, and
115 | associated social media platforms
116 |
117 |
118 | for advertising and marketing, including to send you promotional
119 | information about our products and services and information about
120 | third parties that we consider may be of interest to you
121 |
122 |
123 | to enable you to access and use our website, associated applications,
124 | and associated social media platforms
125 |
126 |
for internal record keeping and administrative purposes
127 |
128 |
129 | Please be aware that we may combine information we collect about you
130 | with general information or research data we receive from other trusted
131 | sources.
132 |
133 |
134 | Security of Your Personal Information
135 |
136 |
137 | When we collect and process personal information, and while we retain
138 | this information, we will protect it within commercially acceptable
139 | means to prevent loss and theft, as well as unauthorized access,
140 | disclosure, copying, use, or modification.
141 |
142 |
143 | Although we will do our best to protect the personal information you
144 | provide to us, we advise that no method of electronic transmission or
145 | storage is 100% secure, and no one can guarantee absolute data security.
146 | We will comply with laws applicable to us in respect of any data breach.
147 |
148 |
149 | You are responsible for selecting any password and its overall security
150 | strength, ensuring the security of your own information within the
151 | bounds of our services.
152 |
153 |
154 | How Long We Keep Your Personal Information
155 |
156 |
157 | We keep your personal information only for as long as we need to. This
158 | time period may depend on what we are using your information for, in
159 | accordance with this privacy policy. If your personal information is no
160 | longer required, we will delete it or make it anonymous by removing all
161 | details that identify you.
162 |
163 |
164 | However, if necessary, we may retain your personal information for our
165 | compliance with a legal, accounting, or reporting obligation or for
166 | archiving purposes in the public interest, scientific, or historical
167 | research purposes or statistical purposes.
168 |
169 |
170 | Children’s Privacy
171 |
172 |
173 | We do not aim any of our products or services directly at children under
174 | the age of 13, and we do not knowingly collect personal information
175 | about children under 13.
176 |
177 |
178 | Disclosure of Personal Information to Third Parties
179 |
180 |
181 | We may disclose personal information to:{" "}
182 |
183 |
184 |
a parent, subsidiary, or affiliate of our company
185 |
186 | third party service providers for the purpose of enabling them to
187 | provide their services, for example, IT service providers, data
188 | storage, hosting and server providers, advertisers, or analytics
189 | platforms
190 |
191 |
our employees, contractors, and/or related entities
192 |
our existing or potential agents or business partners
193 |
194 | sponsors or promoters of any competition, sweepstakes, or promotion we
195 | run
196 |
197 |
198 | courts, tribunals, regulatory authorities, and law enforcement
199 | officers, as required by law, in connection with any actual or
200 | prospective legal proceedings, or in order to establish, exercise, or
201 | defend our legal rights
202 |
203 |
204 | third parties, including agents or sub-contractors, who assist us in
205 | providing information, products, services, or direct marketing to you
206 | third parties to collect and process data
207 |
208 |
209 |
210 | International Transfers of Personal Information
211 |
212 |
213 | The personal information we collect is stored and/or processed where we
214 | or our partners, affiliates, and third-party providers maintain
215 | facilities. Please be aware that the locations to which we store,
216 | process, or transfer your personal information may not have the same
217 | data protection laws as the country in which you initially provided the
218 | information. If we transfer your personal information to third parties
219 | in other countries: (i) we will perform those transfers in accordance
220 | with the requirements of applicable law; and (ii) we will protect the
221 | transferred personal information in accordance with this privacy policy.
222 |
223 |
224 | Your Rights and Controlling Your Personal Information
225 |
226 |
227 | You always retain the right to withhold personal information from us,
228 | with the understanding that your experience of our website may be
229 | affected. We will not discriminate against you for exercising any of
230 | your rights over your personal information. If you do provide us with
231 | personal information you understand that we will collect, hold, use and
232 | disclose it in accordance with this privacy policy. You retain the right
233 | to request details of any personal information we hold about you.
234 |
235 |
236 | If we receive personal information about you from a third party, we will
237 | protect it as set out in this privacy policy. If you are a third party
238 | providing personal information about somebody else, you represent and
239 | warrant that you have such person’s consent to provide the personal
240 | information to us.
241 |
242 |
243 | If you have previously agreed to us using your personal information for
244 | direct marketing purposes, you may change your mind at any time. We will
245 | provide you with the ability to unsubscribe from our email-database or
246 | opt out of communications. Please be aware we may need to request
247 | specific information from you to help us confirm your identity.
248 |
249 |
250 | If you believe that any information we hold about you is inaccurate, out
251 | of date, incomplete, irrelevant, or misleading, please contact us using
252 | the details provided in this privacy policy. We will take reasonable
253 | steps to correct any information found to be inaccurate, incomplete,
254 | misleading, or out of date.
255 |
256 |
257 | If you believe that we have breached a relevant data protection law and
258 | wish to make a complaint, please contact us using the details below and
259 | provide us with full details of the alleged breach. We will promptly
260 | investigate your complaint and respond to you, in writing, setting out
261 | the outcome of our investigation and the steps we will take to deal with
262 | your complaint. You also have the right to contact a regulatory body or
263 | data protection authority in relation to your complaint.
264 |
265 |
266 | Use of Cookies
267 |
268 |
269 | We use “cookies” to collect information about you and your
270 | activity across our site. A cookie is a small piece of data that our
271 | website stores on your computer, and accesses each time you visit, so we
272 | can understand how you use our site. This helps us serve you content
273 | based on preferences you have specified.
274 |
275 |
276 | Limits of Our Policy
277 |
278 |
279 | Our website may link to external sites that are not operated by us.
280 | Please be aware that we have no control over the content and policies of
281 | those sites, and cannot accept responsibility or liability for their
282 | respective privacy practices.
283 |
284 |
285 | Changes to This Policy
286 |
287 |
288 | At our discretion, we may change our privacy policy to reflect updates
289 | to our business processes, current acceptable practices, or legislative
290 | or regulatory changes. If we decide to change this privacy policy, we
291 | will post the changes here at the same link by which you are accessing
292 | this privacy policy.
293 |
294 |
295 | If required by law, we will get your permission or give you the
296 | opportunity to opt in to or opt out of, as applicable, any new uses of
297 | your personal information.
298 |
299 |
300 | Contact Us
301 |
302 |
303 | For any questions or concerns regarding your privacy, you may contact us
304 | using the following details:
305 |
306 |
307 | {appName} Support Team ({email})
308 |
309 |
310 | );
311 | };
312 |
--------------------------------------------------------------------------------
/app/routes/tasks.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Container, Typography } from "@mui/joy";
5 | import { usePageEffect } from "../core/page";
6 |
7 | export const Component = function Tasks(): JSX.Element {
8 | usePageEffect({ title: "Tasks" });
9 |
10 | return (
11 |
12 |
13 | Tasks
14 |
15 | Coming soon...
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/app/routes/terms.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Container, Link, Typography } from "@mui/joy";
5 | import { usePageEffect } from "../core/page";
6 |
7 | const appName = import.meta.env.VITE_APP_NAME;
8 | const appOrigin = import.meta.env.VITE_APP_ORIGIN;
9 |
10 | /**
11 | * Generated by https://getterms.io
12 | */
13 | export const Component = function Terms(): JSX.Element {
14 | usePageEffect({ title: "Terms of Use" });
15 |
16 | return (
17 | x.spacing(3), marginBottom: (x) => x.spacing(3) }}
20 | >
21 |
22 | Terms of Service
23 |
24 |
25 | These Terms of Service govern your use of the website located at{" "}
26 | {appOrigin} and any related services
27 | provided by {appName}.
28 |
29 |
30 | By accessing {appOrigin}, you agree to
31 | abide by these Terms of Service and to comply with all applicable laws
32 | and regulations. If you do not agree with these Terms of Service, you
33 | are prohibited from using or accessing this website or using any other
34 | services provided by {appName}.
35 |
36 |
37 | We, {appName}, reserve the right to review and amend any of these Terms
38 | of Service at our sole discretion. Upon doing so, we will update this
39 | page. Any changes to these Terms of Service will take effect immediately
40 | from the date of publication.
41 |
42 |
43 | These Terms of Service were last updated on 28 February 2021.
44 |
45 |
46 | Limitations of Use
47 |
48 |
49 | By using this website, you warrant on behalf of yourself, your users,
50 | and other parties you represent that you will not:
51 |
52 |
53 |
54 | modify, copy, prepare derivative works of, decompile, or reverse
55 | engineer any materials and software contained on this website;
56 |
57 |
58 | remove any copyright or other proprietary notations from any materials
59 | and software on this website;
60 |
61 |
62 | transfer the materials to another person or “mirror” the materials on
63 | any other server;
64 |
65 |
66 | knowingly or negligently use this website or any of its associated
67 | services in a way that abuses or disrupts our networks or any other
68 | service {appName} provides;
69 |
70 |
71 | use this website or its associated services to transmit or publish any
72 | harassing, indecent, obscene, fraudulent, or unlawful material;
73 |
74 |
75 | use this website or its associated services in violation of any
76 | applicable laws or regulations;
77 |
78 |
79 | use this website in conjunction with sending unauthorized advertising
80 | or spam;
81 |
82 |
83 | harvest, collect, or gather user data without the user’s consent; or
84 |
85 |
86 | use this website or its associated services in such a way that may
87 | infringe the privacy, intellectual property rights, or other rights of
88 | third parties.
89 |
90 |
91 |
92 | Intellectual Property
93 |
94 |
95 | The intellectual property in the materials contained in this website are
96 | owned by or licensed to {appName} and are protected by applicable
97 | copyright and trademark law. We grant our users permission to download
98 | one copy of the materials for personal, non-commercial transitory use.
99 |
100 |
101 | This constitutes the grant of a license, not a transfer of title. This
102 | license shall automatically terminate if you violate any of these
103 | restrictions or the Terms of Service, and may be terminated by {appName}
104 | at any time.
105 |
106 |
107 | Liability
108 |
109 |
110 | Our website and the materials on our website are provided on an
111 | ‘as is’ basis. To the extent permitted by law, {appName}{" "}
112 | makes no warranties, expressed or implied, and hereby disclaims and
113 | negates all other warranties including, without limitation, implied
114 | warranties or conditions of merchantability, fitness for a particular
115 | purpose, or non-infringement of intellectual property, or other
116 | violation of rights.
117 |
118 |
119 | In no event shall {appName} or its suppliers be liable for any
120 | consequential loss suffered or incurred by you or any third party
121 | arising from the use or inability to use this website or the materials
122 | on this website, even if {appName} or an authorized representative has
123 | been notified, orally or in writing, of the possibility of such damage.
124 |
125 |
126 | In the context of this agreement, “consequential loss”
127 | includes any consequential loss, indirect loss, real or anticipated loss
128 | of profit, loss of benefit, loss of revenue, loss of business, loss of
129 | goodwill, loss of opportunity, loss of savings, loss of reputation, loss
130 | of use and/or loss or corruption of data, whether under statute,
131 | contract, equity, tort (including negligence), indemnity, or otherwise.
132 |
133 |
134 | Because some jurisdictions do not allow limitations on implied
135 | warranties, or limitations of liability for consequential or incidental
136 | damages, these limitations may not apply to you.
137 |
138 |
139 | Accuracy of Materials
140 |
141 |
142 | The materials appearing on our website are not comprehensive and are for
143 | general information purposes only. {appName} does not warrant or make
144 | any representations concerning the accuracy, likely results, or
145 | reliability of the use of the materials on this website, or otherwise
146 | relating to such materials or on any resources linked to this website.
147 |
148 |
149 | Links
150 |
151 |
152 | {appName} has not reviewed all of the sites linked to its website and is
153 | not responsible for the contents of any such linked site. The inclusion
154 | of any link does not imply endorsement, approval, or control by
155 | {appName} of the site. Use of any such linked site is at your own risk
156 | and we strongly advise you make your own investigations with respect to
157 | the suitability of those sites.
158 |
159 |
160 | Right to Terminate
161 |
162 |
163 | We may suspend or terminate your right to use our website and terminate
164 | these Terms of Service immediately upon written notice to you for any
165 | breach of these Terms of Service.
166 |
167 |
168 | Severance
169 |
170 |
171 | Any term of these Terms of Service which is wholly or partially void or
172 | unenforceable is severed to the extent that it is void or unenforceable.
173 | The validity of the remainder of these Terms of Service is not affected.
174 |
175 |
176 | Governing Law
177 |
178 |
179 | These Terms of Service are governed by and construed in accordance with
180 | the laws of United States. You irrevocably submit to the exclusive
181 | jurisdiction of the courts in that State or location.
182 |
183 |
184 | );
185 | };
186 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "jsx": "react-jsx",
6 | "jsxImportSource": "@emotion/react",
7 | "types": ["vite/client"],
8 | "outDir": "../.cache/typescript-app",
9 | "module": "ESNext",
10 | "moduleResolution": "Bundler",
11 | "noEmit": true
12 | },
13 | "include": ["**/*.ts", "**/*.d.ts", "**/*.tsx", "**/*.json"],
14 | "exclude": ["dist/**/*", "vite.config.ts"],
15 | "references": [{ "path": "./tsconfig.node.json" }]
16 | }
17 |
--------------------------------------------------------------------------------
/app/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "moduleResolution": "Node",
6 | "types": ["vite/client"],
7 | "allowSyntheticDefaultImports": true,
8 | "outDir": "../.cache/typescript-app",
9 | "emitDeclarationOnly": true
10 | },
11 | "include": ["vite.config.ts", "core/config.ts"]
12 | }
13 |
--------------------------------------------------------------------------------
/app/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import react from "@vitejs/plugin-react";
5 | import { URL, fileURLToPath } from "node:url";
6 | import { loadEnv } from "vite";
7 | import { defineProject } from "vitest/config";
8 |
9 | const publicEnvVars = [
10 | "APP_ENV",
11 | "APP_NAME",
12 | "APP_ORIGIN",
13 | "GOOGLE_CLOUD_PROJECT",
14 | "FIREBASE_APP_ID",
15 | "FIREBASE_API_KEY",
16 | "FIREBASE_AUTH_DOMAIN",
17 | "GA_MEASUREMENT_ID",
18 | ];
19 |
20 | /**
21 | * Vite configuration.
22 | * https://vitejs.dev/config/
23 | */
24 | export default defineProject(async ({ mode }) => {
25 | const envDir = fileURLToPath(new URL("..", import.meta.url));
26 | const env = loadEnv(mode, envDir, "");
27 |
28 | publicEnvVars.forEach((key) => {
29 | if (!env[key]) throw new Error(`Missing environment variable: ${key}`);
30 | process.env[`VITE_${key}`] = env[key];
31 | });
32 |
33 | return {
34 | cacheDir: fileURLToPath(new URL("../.cache/vite-app", import.meta.url)),
35 |
36 | build: {
37 | rollupOptions: {
38 | output: {
39 | manualChunks: {
40 | firebase: ["firebase/analytics", "firebase/app", "firebase/auth"],
41 | react: ["react", "react-dom", "react-router-dom"],
42 | },
43 | },
44 | },
45 | },
46 |
47 | plugins: [
48 | // The default Vite plugin for React projects
49 | // https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md
50 | react({
51 | jsxImportSource: "@emotion/react",
52 | babel: {
53 | plugins: ["@emotion/babel-plugin"],
54 | },
55 | }),
56 | ],
57 |
58 | server: {
59 | proxy: {
60 | "/api": {
61 | target: process.env.LOCAL_API_ORIGIN ?? process.env.API_ORIGIN,
62 | changeOrigin: true,
63 | },
64 | },
65 | },
66 |
67 | test: {
68 | ...{ cache: { dir: "../.cache/vitest" } },
69 | environment: "happy-dom",
70 | },
71 | };
72 | });
73 |
--------------------------------------------------------------------------------
/db/README.md:
--------------------------------------------------------------------------------
1 | # Firestore Database
2 |
3 | Database schema, security rules, indexes, and seed data for the [Firestore](https://cloud.google.com/firestore) database.
4 |
5 | ## Directory Structure
6 |
7 | - [`/models`](./models/) — Database schema definitions using [Zod](https://zod.dev/).
8 | - [`/seeds`](./seeds/) — Sample / reference data for the database.
9 | - [`/scripts`](./scripts/) — Scripts for managing the database.
10 | - [`/firestore.indexes.json`](./firestore.indexes.json) — Firestore indexes.
11 | - [`/firestore.rules`](./firestore.rules) — Firestore security rules.
12 |
13 | ## Scripts
14 |
15 | - `yarn workspace db seed` - Seed the database with data from [`/seeds`](./seeds/).
16 |
17 | ## References
18 |
19 | - https://zod.dev/
20 | - https://cloud.google.com/firestore
21 |
--------------------------------------------------------------------------------
/db/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [
3 | {
4 | "collectionGroup": "workspace",
5 | "queryScope": "COLLECTION",
6 | "fields": [
7 | { "fieldPath": "ownerId", "order": "ASCENDING" },
8 | { "fieldPath": "archived", "order": "DESCENDING" }
9 | ]
10 | }
11 | ],
12 | "fieldOverrides": []
13 | }
14 |
--------------------------------------------------------------------------------
/db/firestore.rules:
--------------------------------------------------------------------------------
1 | // Firestore security rules.
2 | // https://cloud.google.com/firestore/docs/security/get-started
3 |
4 | rules_version = '2';
5 |
6 | service cloud.firestore {
7 | match /databases/{database}/documents {
8 | match /workspace/{id} {
9 | allow read: if request.auth != null && (
10 | resource.data.ownerId = request.auth.uid ||
11 | request.auth.token.admin == true
12 | );
13 | }
14 |
15 | match /{document=**} {
16 | allow read, write: if false;
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/db/index.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | export * from "./models";
5 | export { testUsers } from "./seeds/01-users";
6 | export { testWorkspaces } from "./seeds/02-workspaces";
7 |
--------------------------------------------------------------------------------
/db/models/index.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | export * from "./workspace";
5 |
--------------------------------------------------------------------------------
/db/models/workspace.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Timestamp } from "@google-cloud/firestore";
5 | import { z } from "zod";
6 |
7 | export const WorkspaceSchema = z.object({
8 | name: z.string().max(100),
9 | ownerId: z.string().max(50),
10 | created: z.instanceof(Timestamp),
11 | updated: z.instanceof(Timestamp),
12 | archived: z.instanceof(Timestamp).nullable(),
13 | });
14 |
15 | export type Workspace = z.output;
16 | export type WorkspaceInput = z.input;
17 |
--------------------------------------------------------------------------------
/db/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "db",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | ".": {
8 | "default": "./index.ts"
9 | },
10 | "./package.json": "./package.json"
11 | },
12 | "scripts": {
13 | "seed": "vite-node ./scripts/seed.ts",
14 | "test": "vitest"
15 | },
16 | "dependencies": {
17 | "@google-cloud/firestore": "^7.3.0",
18 | "@googleapis/identitytoolkit": "^8.0.1",
19 | "zod": "^3.22.4"
20 | },
21 | "devDependencies": {
22 | "@types/node": "^20.11.18",
23 | "dotenv": "^16.4.4",
24 | "ora": "^8.0.1",
25 | "typescript": "~5.3.3",
26 | "vite": "~5.1.2",
27 | "vite-node": "~1.2.2",
28 | "vitest": "~1.2.2"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/db/scripts/seed.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Firestore } from "@google-cloud/firestore";
5 | import { configDotenv } from "dotenv";
6 | import { relative, resolve } from "node:path";
7 | import { oraPromise } from "ora";
8 |
9 | const rootDir = resolve(__dirname, "../..");
10 |
11 | // Load environment variables from .env files.
12 | configDotenv({ path: resolve(rootDir, ".env.local") });
13 | configDotenv({ path: resolve(rootDir, ".env") });
14 |
15 | let db: Firestore | null = null;
16 |
17 | // Seed the database with test / sample data.
18 | try {
19 | db = new Firestore({
20 | projectId: process.env.GOOGLE_CLOUD_PROJECT,
21 | databaseId: process.env.GOOGLE_CLOUD_DATABASE,
22 | });
23 |
24 | // Import all seed modules from the `/seeds` folder.
25 | const files = import.meta.glob("../seeds/*.ts");
26 |
27 | // Sequentially seed the database with data from each module.
28 | for (const [path, load] of Object.entries(files)) {
29 | const message = `Seeding ${relative("../seeds", path)}`;
30 | const action = (async () => {
31 | const { seed } = await load();
32 | await seed(db);
33 | })();
34 |
35 | await oraPromise(action, message);
36 | }
37 | } finally {
38 | await db?.terminate();
39 | }
40 |
41 | type SeedModule = {
42 | seed: (db: Firestore) => Promise;
43 | };
44 |
--------------------------------------------------------------------------------
/db/seeds/01-users.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import {
5 | AuthPlus,
6 | identitytoolkit,
7 | identitytoolkit_v3,
8 | } from "@googleapis/identitytoolkit";
9 |
10 | /**
11 | * Test user accounts generated by https://randomuser.me/.
12 | */
13 | export const testUsers: identitytoolkit_v3.Schema$UserInfo[] = [
14 | {
15 | localId: "test-erika",
16 | screenName: "erika",
17 | email: "erika.pearson@example.com",
18 | emailVerified: true,
19 | phoneNumber: "+14788078434",
20 | displayName: "Erika Pearson",
21 | photoUrl: "https://randomuser.me/api/portraits/women/29.jpg",
22 | rawPassword: "paloma",
23 | createdAt: new Date("2024-01-01T12:00:00Z").getTime().toString(),
24 | lastLoginAt: new Date("2024-01-01T12:00:00Z").getTime().toString(),
25 | },
26 | {
27 | localId: "test-ryan",
28 | screenName: "ryan",
29 | email: "ryan.hunt@example.com",
30 | emailVerified: true,
31 | phoneNumber: "+16814758216",
32 | displayName: "Ryan Hunt",
33 | photoUrl: "https://randomuser.me/api/portraits/men/20.jpg",
34 | rawPassword: "baggins",
35 | createdAt: new Date("2024-01-02T12:00:00Z").getTime().toString(),
36 | lastLoginAt: new Date("2024-01-02T12:00:00Z").getTime().toString(),
37 | },
38 | {
39 | localId: "test-marian",
40 | screenName: "marian",
41 | email: "marian.stone@example.com",
42 | emailVerified: true,
43 | phoneNumber: "+19243007975",
44 | displayName: "Marian Stone",
45 | photoUrl: "https://randomuser.me/api/portraits/women/2.jpg",
46 | rawPassword: "winter1",
47 | createdAt: new Date("2024-01-03T12:00:00Z").getTime().toString(),
48 | lastLoginAt: new Date("2024-01-03T12:00:00Z").getTime().toString(),
49 | },
50 | {
51 | localId: "test-kurt",
52 | screenName: "kurt",
53 | email: "kurt.howward@example.com",
54 | emailVerified: true,
55 | phoneNumber: "+19243007975",
56 | displayName: "Kurt Howard",
57 | photoUrl: "https://randomuser.me/api/portraits/men/23.jpg",
58 | rawPassword: "mayday",
59 | createdAt: new Date("2024-01-04T12:00:00Z").getTime().toString(),
60 | lastLoginAt: new Date("2024-01-04T12:00:00Z").getTime().toString(),
61 | },
62 | {
63 | localId: "test-dan",
64 | screenName: "dan",
65 | email: "dan.day@example.com",
66 | emailVerified: true,
67 | phoneNumber: "+12046748092",
68 | displayName: "Dan Day",
69 | photoUrl: "https://randomuser.me/api/portraits/men/65.jpg",
70 | rawPassword: "teresa",
71 | createdAt: new Date("2024-01-05T12:00:00Z").getTime().toString(),
72 | lastLoginAt: new Date("2024-01-05T12:00:00Z").getTime().toString(),
73 | customAttributes: JSON.stringify({ admin: true }),
74 | },
75 | ];
76 |
77 | /**
78 | * Seeds the Google Identity Platform (Firebase Auth) with test user accounts.
79 | *
80 | * @see https://randomuser.me/
81 | * @see https://cloud.google.com/identity-platform
82 | */
83 | export async function seed() {
84 | const auth = new AuthPlus();
85 | const { relyingparty } = identitytoolkit({ version: "v3", auth });
86 | await relyingparty.uploadAccount({ requestBody: { users: testUsers } });
87 | }
88 |
--------------------------------------------------------------------------------
/db/seeds/02-workspaces.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Firestore, Timestamp } from "@google-cloud/firestore";
5 | import { WorkspaceInput } from "../models";
6 | import { testUsers as users } from "./01-users";
7 |
8 | /**
9 | * Test workspaces.
10 | */
11 | export const testWorkspaces: (WorkspaceInput & { id: string })[] = [
12 | {
13 | id: "DwYchGFGpk",
14 | ownerId: users[0].localId!,
15 | name: "Personal workspace",
16 | created: Timestamp.fromDate(new Date(+users[0].createdAt!)),
17 | updated: Timestamp.fromDate(new Date(+users[0].createdAt!)),
18 | archived: null,
19 | },
20 | {
21 | id: "YfYKTcO9q9",
22 | ownerId: users[1].localId!,
23 | name: "Personal workspace",
24 | created: Timestamp.fromDate(new Date(+users[1].createdAt!)),
25 | updated: Timestamp.fromDate(new Date(+users[1].createdAt!)),
26 | archived: null,
27 | },
28 | {
29 | id: "c2OsmUvFMY",
30 | ownerId: users[2].localId!,
31 | name: "Personal workspace",
32 | created: Timestamp.fromDate(new Date(+users[2].createdAt!)),
33 | updated: Timestamp.fromDate(new Date(+users[2].createdAt!)),
34 | archived: null,
35 | },
36 | {
37 | id: "uTqcGw4qn7",
38 | ownerId: users[3].localId!,
39 | name: "Personal workspace",
40 | created: Timestamp.fromDate(new Date(+users[3].createdAt!)),
41 | updated: Timestamp.fromDate(new Date(+users[3].createdAt!)),
42 | archived: null,
43 | },
44 | {
45 | id: "vBHHgg5ydn",
46 | ownerId: users[4].localId!,
47 | name: "Personal workspace",
48 | created: Timestamp.fromDate(new Date(+users[4].createdAt!)),
49 | updated: Timestamp.fromDate(new Date(+users[4].createdAt!)),
50 | archived: null,
51 | },
52 | ];
53 |
54 | export async function seed(db: Firestore) {
55 | const batch = db.batch();
56 |
57 | for (const { id, ...workspace } of testWorkspaces) {
58 | const ref = db.doc(`workspace/${id}`);
59 | batch.set(ref, workspace, { merge: true });
60 | }
61 |
62 | await batch.commit();
63 | }
64 |
--------------------------------------------------------------------------------
/db/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "emitDeclarationOnly": true,
6 | "module": "ESNext",
7 | "moduleResolution": "Bundler",
8 | "noEmit": false,
9 | "outDir": "../.cache/ts-db",
10 | "types": ["node", "vite/client"]
11 | },
12 | "include": ["**/*.ts", "**/*.json"],
13 | "exclude": ["**/dist/**/*", "**/node_modules/**/*"]
14 | }
15 |
--------------------------------------------------------------------------------
/edge/README.md:
--------------------------------------------------------------------------------
1 | # CDN edge endpoint
2 |
3 | CDN edge endpoint powered by [Cloudflare Workers](https://workers.cloudflare.com/) that serves the front-end app.
4 |
5 | ## Directory Structure
6 |
7 | `├──`[`core`](./core) — Core application modules
8 | `├──`[`routes`](./routes) — API routes (endpoints)
9 | `├──`[`global.d.ts`](./global.d.ts) — Global TypeScript declarations
10 | `├──`[`index.ts`](./index.tsx) — Cloudflare Worker entry point
11 | `├──`[`package.json`](./package.json) — The list of dependencies
12 | `├──`[`tsconfig.ts`](./tsconfig.json) — TypeScript configuration ([docs](https://www.typescriptlang.org/tsconfig))
13 | `├──`[`vite.config.ts`](./vite.config.ts) — JavaScript bundler configuration ([docs](https://vitejs.dev/config/))
14 | `└──`[`wrangler.toml`](./wrangler.toml) — Wrangler CLI configuration ([docs](https://developers.cloudflare.com/workers/wrangler/configuration/))
15 |
16 | ## Getting Started
17 |
18 | Test the app locally using [Vitest](https://vitejs.dev/):
19 |
20 | ```
21 | $ yarn workspace edge test
22 | ```
23 |
24 | Build and deploy the app by running:
25 |
26 | ```
27 | $ yarn workspace app build
28 | $ yarn workspace edge build
29 | $ yarn workspace edge deploy [--env #0]
30 | ```
31 |
32 | Start a session to livestream logs from a deployed Worker:
33 |
34 | ```
35 | $ yarn workspace edge wrangler tail [--env #0]
36 | ```
37 |
38 | Where `--env` is one of the supported environments, such as `--env=prod`, `--env=test` (default).
39 |
40 | ## Scripts
41 |
42 | - `build` — Build the app for production
43 | - `test` — Run unit tests
44 | - `coverage` — Run unit tests with enabled coverage report
45 | - `deploy [--env #0]` — Deploy the app to Cloudflare (CDN)
46 | - `wrangler [--env #0]` — Wrangler CLI (wrapper)
47 |
48 | ## References
49 |
50 | - https://hono.dev/ — JavaScript framework for CDN edge endpoints
51 | - https://developers.cloudflare.com/workers/ — Cloudflare Workers docs
52 | - https://www.typescriptlang.org/ — TypeScript reference
53 | - https://vitejs.dev/ — Front-end tooling (bundler)
54 | - https://vitest.dev/ — Unit test framework
55 |
--------------------------------------------------------------------------------
/edge/core/app.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Hono } from "hono";
5 |
6 | /**
7 | * Application router for Cloudflare Workers
8 | * @see https://honojs.dev/
9 | */
10 | export const app = new Hono();
11 |
12 | app.onError((err, ctx) => {
13 | console.error(err.stack);
14 | return ctx.text(err.stack ?? "Application error", 500, {
15 | "Content-Type": "text/plain",
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/edge/core/email.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | /**
5 | * Sends an email message using SendGrid API.
6 | *
7 | * @example
8 | * sendEmail({
9 | * to: [{ email: "user@example.com" }],
10 | * // https://mc.sendgrid.com/dynamic-templates
11 | * templateId: "d-12345678501234537890143456789511",
12 | * templateData: { name: "John" },
13 | * waitUntil: ctx.executionCtx.waitUntil,
14 | * env: ctx.env,
15 | * });
16 | *
17 | * @see https://docs.sendgrid.com/api-reference/mail-send/mail-send
18 | */
19 | export function sendEmail(options: Options) {
20 | const { env, to, subject, templateData, templateId, waitUntil } = options;
21 |
22 | const inFlight = (async function send() {
23 | const data = {
24 | personalizations: [{ to, dynamic_template_data: templateData }],
25 | from: { email: env.FROM_EMAIL, name: env.APP_NAME },
26 | template_id: templateId,
27 | subject,
28 | };
29 |
30 | const req = new Request("https://api.sendgrid.com/v3/mail/send", {
31 | method: "POST",
32 | headers: {
33 | ["Authorization"]: `Bearer ${env.SENDGRID_API_KEY}`,
34 | ["Content-Type"]: "application/json",
35 | },
36 | body: JSON.stringify(data),
37 | });
38 |
39 | const res = await fetch(req, options.req);
40 |
41 | if (!res.ok) {
42 | const body = await res.json().catch(() => undefined);
43 | console.error({
44 | req: { url: req.url, method: req.method },
45 | res: {
46 | status: res.status,
47 | statusText: res.statusText,
48 | errors: (body as ErrorResponse)?.errors,
49 | },
50 | });
51 |
52 | throw new Error("Failed to send an email message.");
53 | }
54 | })();
55 |
56 | waitUntil?.(inFlight);
57 |
58 | return inFlight;
59 | }
60 |
61 | // #region TypeScript types
62 |
63 | type Options = {
64 | to: [{ email: string; name?: string }];
65 | subject?: string;
66 | templateId: string;
67 | templateData: Record;
68 | env: {
69 | SENDGRID_API_KEY: string;
70 | FROM_EMAIL: string;
71 | APP_NAME: string;
72 | };
73 | waitUntil: (promise: Promise) => void;
74 | req?: RequestInit;
75 | };
76 |
77 | type ErrorResponse = {
78 | errors: [{ message: string }];
79 | };
80 |
81 | // #endregion
82 |
--------------------------------------------------------------------------------
/edge/core/manifest.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | /**
5 | * Cloudflare Workers content manifest fallback (for unit testing).
6 | */
7 | export default "{}";
8 |
--------------------------------------------------------------------------------
/edge/global.d.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | declare module "__STATIC_CONTENT_MANIFEST" {
5 | const JSON: string;
6 | export default JSON;
7 | }
8 |
9 | declare type Bindings = {
10 | APP_ENV: "local" | "test" | "prod";
11 | APP_NAME: string;
12 | APP_HOSTNAME: string;
13 | FIREBASE_APP_ID: string;
14 | FIREBASE_API_KEY: string;
15 | FIREBASE_AUTH_DOMAIN: string;
16 | GOOGLE_CLOUD_PROJECT: string;
17 | GOOGLE_CLOUD_CREDENTIALS: string;
18 | SENDGRID_API_KEY: string;
19 | FROM_EMAIL: string;
20 | __STATIC_CONTENT: KVNamespace;
21 | };
22 |
23 | declare type Env = {
24 | Bindings: Bindings;
25 | };
26 |
27 | declare function getMiniflareBindings(): T;
28 |
--------------------------------------------------------------------------------
/edge/index.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { app } from "./core/app.js";
5 | // Register `/__/*` middleware
6 | import "./routes/firebase.js";
7 | // Register `/api/login` route handler
8 | import "./routes/api-login.js";
9 | // Register `/api/*` middleware
10 | import "./routes/api-swapi.js";
11 | // Register `/echo` route handler
12 | import "./routes/echo.js";
13 | // Register `*` static assets handler
14 | import "./routes/assets.js";
15 |
16 | export default app;
17 |
--------------------------------------------------------------------------------
/edge/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "edge",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "vite build",
8 | "test": "vitest",
9 | "coverage": "vitest run --coverage",
10 | "deploy": "node ../scripts/wrangler.js deploy",
11 | "logs": "node ../scripts/wrangler.js tail",
12 | "wrangler": "node ../scripts/wrangler.js",
13 | "edge:cf": "node ../scripts/wrangler.js",
14 | "edge:tsc": "tsc",
15 | "edge:test": "vitest",
16 | "edge:build": "vite build",
17 | "edge:deploy": "node ../scripts/wrangler.js deploy",
18 | "edge:logs": "node ../scripts/wrangler.js tail"
19 | },
20 | "dependencies": {
21 | "@hono/zod-validator": "^0.1.11",
22 | "hono": "^4.0.2",
23 | "jose": "^5.2.2",
24 | "web-auth-library": "^1.0.3",
25 | "zod": "^3.22.4"
26 | },
27 | "devDependencies": {
28 | "@cloudflare/workers-types": "^4.20240208.0",
29 | "@types/node": "^20.11.18",
30 | "happy-dom": "^13.3.8",
31 | "toml": "^3.0.0",
32 | "typescript": "~5.3.3",
33 | "vite": "~5.1.2",
34 | "vitest": "~1.2.2",
35 | "vitest-environment-miniflare": "^2.14.2"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/edge/routes/api-login.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { zValidator } from "@hono/zod-validator";
5 | import { z } from "zod";
6 | import { app } from "../core/app.js";
7 |
8 | /**
9 | * Authenticates the user with an email address and a one-time code (OTP).
10 | */
11 | export const handler = app.post(
12 | "/api/login",
13 |
14 | // Validate the request body using Zod
15 | zValidator(
16 | "json",
17 | z.object({
18 | email: z.string({
19 | required_error: "Email is required",
20 | }),
21 | code: z.string().optional(),
22 | }),
23 | ),
24 |
25 | // Handle the request
26 | ({ req, json }) => {
27 | const input = req.valid("json");
28 | // TODO: Implement the login logic
29 | return json({ email: input.email });
30 | },
31 | );
32 |
33 | export type LoginHandler = typeof handler;
34 | export type LoginResponse = {
35 | email: string;
36 | };
37 |
--------------------------------------------------------------------------------
/edge/routes/api-swapi.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { app } from "../core/app.js";
5 |
6 | // Rewrite HTTP requests starting with "/api/"
7 | // to the Star Wars API as an example
8 | export const handler = app.use("/api/*", async ({ req }) => {
9 | const { pathname, search } = new URL(req.url);
10 | const res = await fetch(
11 | `https://swapi.dev${pathname}${search}`,
12 | req.raw as RequestInit,
13 | );
14 | return res as unknown as Response;
15 | });
16 |
17 | export type SwapiHandler = typeof handler;
18 |
--------------------------------------------------------------------------------
/edge/routes/assets.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { serveStatic } from "hono/cloudflare-workers";
5 | import { getMimeType } from "hono/utils/mime";
6 | import assetManifest from "__STATIC_CONTENT_MANIFEST";
7 | import { app } from "../core/app.js";
8 |
9 | const manifest = JSON.parse(assetManifest);
10 |
11 | // Static assets handler
12 | // https://hono.dev/getting-started/cloudflare-workers#serve-static-files
13 | const asset = serveStatic({ manifest });
14 | const fallback = serveStatic({ path: "/index.html", manifest });
15 |
16 | // Serve web application assets bundled into
17 | // the worker script from the `../app/dist` folder
18 | export const handler = app.use("*", async (ctx, next) => {
19 | const url = new URL(ctx.req.url);
20 |
21 | // Alternatively, import the list of routes from the `app` package
22 | const isKnownRoute = [
23 | "",
24 | "/",
25 | "/dashboard",
26 | "/settings",
27 | "/settings/account",
28 | "/login",
29 | "/signup",
30 | "/privacy",
31 | "/terms",
32 | ].includes(url.pathname);
33 |
34 | // Serve index.html for known URL routes
35 | if (isKnownRoute) {
36 | return await fallback(ctx, next);
37 | }
38 |
39 | // Otherwise attempt to serve the static asset (file)
40 | const res = await asset(ctx, next);
41 |
42 | // Serve index.html for unknown URL routes with 404 status code
43 | if (!res && !getMimeType(url.pathname)) {
44 | const res = await fallback(ctx, next);
45 |
46 | if (res) {
47 | return new Response(res.body, { ...res, status: 404 });
48 | }
49 | }
50 |
51 | return res;
52 | });
53 |
54 | export type AssetsHandler = typeof handler;
55 |
--------------------------------------------------------------------------------
/edge/routes/echo.test.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { expect, test } from "vitest";
5 | import { handler } from "./echo.js";
6 |
7 | test("GET /echo", async () => {
8 | // Initialize an HTTP GET request object
9 | const env = getMiniflareBindings();
10 | const req = new Request(`https://${env.APP_HOSTNAME}/echo`);
11 | req.headers.set("Content-Type", "application/json");
12 |
13 | // Fetch the target URL and parse the response
14 | const res = await handler.fetch(req, env);
15 | const body = await res
16 | .json()
17 | .catch(() => res.text())
18 | .catch(() => undefined);
19 |
20 | // Compare the response with the expected result
21 | expect({ status: res.status, body }).toEqual({
22 | status: 200,
23 | body: {
24 | headers: {
25 | "content-type": "application/json",
26 | },
27 | },
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/edge/routes/echo.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { app } from "../core/app.js";
5 |
6 | export const handler = app.get("/echo", ({ json, req }) => {
7 | return json({
8 | headers: Object.fromEntries(req.raw.headers.entries()),
9 | cf: req.raw.cf,
10 | });
11 | });
12 |
13 | export type EchoHandler = typeof handler;
14 |
--------------------------------------------------------------------------------
/edge/routes/firebase.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { app } from "../core/app";
5 |
6 | export const handler = app.use("/__/*", async ({ req, env }) => {
7 | const url = new URL(req.url);
8 | const origin = `https://${env.GOOGLE_CLOUD_PROJECT}.web.app`;
9 | const res = await fetch(`${origin}${url.pathname}${url.search}`, req.raw);
10 | return res as unknown as Response;
11 | });
12 |
13 | export type FirebaseHandler = typeof handler;
14 |
--------------------------------------------------------------------------------
/edge/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "lib": ["ESNext"],
5 | "types": ["@cloudflare/workers-types", "vite/client"],
6 | "outDir": "../.cache/typescript-edge"
7 | },
8 | "include": ["**/*.ts", "**/*.d.ts", "**/*.json"],
9 | "exclude": ["dist/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/edge/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2020-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { resolve } from "node:path";
5 | import { defineProject } from "vitest/config";
6 | import { getCloudflareBindings } from "../scripts/utils.js";
7 |
8 | export default defineProject({
9 | cacheDir: "../.cache/vite-edge",
10 |
11 | // Production build configuration
12 | // https://vitejs.dev/guide/build
13 | build: {
14 | lib: {
15 | entry: "index.ts",
16 | fileName: "index",
17 | formats: ["es"],
18 | },
19 | rollupOptions: {
20 | external: ["__STATIC_CONTENT_MANIFEST"],
21 | },
22 | },
23 |
24 | resolve: {
25 | alias: {
26 | ["__STATIC_CONTENT_MANIFEST"]: resolve("./core/manifest.ts"),
27 | },
28 | },
29 |
30 | // Unit testing configuration
31 | // https://vitest.dev/config/
32 | test: {
33 | ...{ cache: { dir: resolve(__dirname, "../.cache/vitest") } },
34 | deps: {
35 | // ...{ registerNodeLoader: true },
36 | external: ["__STATIC_CONTENT_MANIFEST"],
37 | },
38 | environment: "miniflare",
39 | environmentOptions: {
40 | bindings: getCloudflareBindings(resolve(__dirname, "wrangler.toml")),
41 | },
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/edge/wrangler.toml:
--------------------------------------------------------------------------------
1 | # Cloudflare Workers configuration
2 | # https://developers.cloudflare.com/workers/wrangler/configuration/
3 |
4 | name = "example"
5 | main = "index.js"
6 |
7 | # https://developers.cloudflare.com/workers/platform/compatibility-dates/
8 | compatibility_date = "2023-03-14"
9 | send_metrics = false
10 |
11 | account_id = "${CLOUDFLARE_ACCOUNT_ID}"
12 |
13 | routes = [
14 | { pattern = "${APP_HOSTNAME}/*", zone_id = "${CLOUDFLARE_ZONE_ID}" }
15 | ]
16 |
17 | rules = [
18 | { type = "ESModule", globs = ["dist/*.js"] },
19 | { type = "Text", globs = ["dist/*.md"], fallthrough = true }
20 | ]
21 |
22 | [vars]
23 | APP_ENV = "${APP_ENV}"
24 | APP_NAME = "${APP_NAME}"
25 | APP_HOSTNAME = "${APP_HOSTNAME}"
26 | GOOGLE_CLOUD_PROJECT = "${GOOGLE_CLOUD_PROJECT}"
27 | FIREBASE_API_KEY = "${FIREBASE_API_KEY}"
28 | FROM_EMAIL = "${FROM_EMAIL}"
29 |
30 | # [secrets]
31 | # GOOGLE_CLOUD_CREDENTIALS
32 |
33 | [site]
34 | bucket = "../../app/dist"
35 |
36 | [env.test]
37 | name = "example-test"
38 |
39 | [env.test.vars]
40 | APP_ENV = "${APP_ENV}"
41 | APP_NAME = "${APP_NAME}"
42 | APP_HOSTNAME = "${APP_HOSTNAME}"
43 | GOOGLE_CLOUD_PROJECT = "${GOOGLE_CLOUD_PROJECT}"
44 | FIREBASE_API_KEY = "${FIREBASE_API_KEY}"
45 | FROM_EMAIL = "${FROM_EMAIL}"
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "root",
3 | "version": "0.0.0",
4 | "packageManager": "yarn@4.1.0",
5 | "private": true,
6 | "type": "module",
7 | "workspaces": [
8 | "app",
9 | "db",
10 | "edge",
11 | "scripts",
12 | "server"
13 | ],
14 | "scripts": {
15 | "postinstall": "husky install && node ./scripts/post-install.js",
16 | "start": "yarn workspace app start",
17 | "lint": "eslint --cache --report-unused-disable-directives .",
18 | "test": "vitest",
19 | "build": "yarn workspaces foreach -tiA run build",
20 | "deploy": "yarn workspace edge deploy"
21 | },
22 | "devDependencies": {
23 | "@emotion/babel-plugin": "^11.11.0",
24 | "@emotion/eslint-plugin": "^11.11.0",
25 | "@types/eslint": "^8.56.2",
26 | "@typescript-eslint/eslint-plugin": "^7.0.1",
27 | "@typescript-eslint/parser": "^7.0.1",
28 | "eslint": "^8.56.0",
29 | "eslint-config-prettier": "^9.1.0",
30 | "eslint-import-resolver-typescript": "^3.6.1",
31 | "eslint-plugin-import": "^2.29.1",
32 | "eslint-plugin-jsx-a11y": "^6.8.0",
33 | "eslint-plugin-react": "^7.33.2",
34 | "eslint-plugin-react-hooks": "^4.6.0",
35 | "graphql": "^16.8.1",
36 | "happy-dom": "^13.3.8",
37 | "husky": "^9.0.11",
38 | "prettier": "^3.2.5",
39 | "react": "^18.2.0",
40 | "relay-config": "^12.0.1",
41 | "typescript": "~5.3.3",
42 | "vite": "~5.1.2",
43 | "vitest": "~1.2.2"
44 | },
45 | "prettier": {
46 | "printWidth": 80,
47 | "tabWidth": 2,
48 | "useTabs": false,
49 | "semi": true,
50 | "singleQuote": false,
51 | "quoteProps": "as-needed",
52 | "jsxSingleQuote": false,
53 | "trailingComma": "all",
54 | "bracketSpacing": true,
55 | "bracketSameLine": false,
56 | "arrowParens": "always",
57 | "endOfLine": "lf"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/scripts/bundle-yarn.js:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { execa } from "execa";
5 | import { copyFile, readFile, writeFile } from "node:fs/promises";
6 | import { resolve } from "node:path";
7 | import { fileURLToPath } from "node:url";
8 |
9 | const rootDir = resolve(fileURLToPath(import.meta.url), "../..");
10 | const rootPkg = JSON.parse(await readFile(`${rootDir}/package.json`, "utf-8"));
11 | const pkg = JSON.parse(await readFile("./package.json", "utf-8"));
12 |
13 | pkg.packageManager = rootPkg.packageManager;
14 | delete pkg.scripts;
15 | delete pkg.devDependencies;
16 | delete pkg.dependencies.db;
17 |
18 | // Create ./dist/package.json
19 | await writeFile("./dist/package.json", JSON.stringify(pkg, null, 2), "utf-8");
20 |
21 | // Create ./dist/yarn.lock
22 | await copyFile(`${rootDir}/yarn.lock`, "./dist/yarn.lock");
23 |
24 | // Install production dependencies
25 | await execa("yarn", ["install", "--mode=update-lockfile"], {
26 | env: {
27 | ...process.env,
28 | NODE_OPTIONS: undefined,
29 | YARN_ENABLE_IMMUTABLE_INSTALLS: "false",
30 | },
31 | cwd: "./dist",
32 | stdio: "inherit",
33 | });
34 |
--------------------------------------------------------------------------------
/scripts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scripts",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "dependencies": {
7 | "dotenv": "^16.4.4",
8 | "execa": "^8.0.1",
9 | "get-port": "^7.0.0",
10 | "got": "^13.0.0",
11 | "graphql": "^16.8.1",
12 | "lodash-es": "^4.17.21",
13 | "miniflare": "^3.20240129.2",
14 | "prettier": "^3.2.5",
15 | "toml": "^3.0.0",
16 | "vite": "^5.1.2",
17 | "wrangler": "^3.28.2",
18 | "zx": "^7.2.3"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/scripts/post-install.js:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { execa } from "execa";
5 | import fs from "node:fs";
6 | import { EOL } from "node:os";
7 |
8 | // Create Git-ignored files for environment variable overrides
9 | if (!fs.existsSync("./.env.local")) {
10 | await fs.writeFile(
11 | "./.env.local",
12 | [
13 | `# Overrides for the \`.env\` file in the root folder.`,
14 | "#",
15 | "# CLOUDFLARE_API_TOKEN=xxxxx",
16 | "# GOOGLE_CLOUD_CREDENTIALS=xxxxx",
17 | "# SENDGRID_API_KEY=xxxxx",
18 | "#",
19 | "",
20 | "API_URL=http://localhost:8080",
21 | "",
22 | ].join(EOL),
23 | "utf-8",
24 | );
25 | }
26 |
27 | try {
28 | await execa("yarn", ["tsc", "--build"], { stdin: "inherit" });
29 | } catch (err) {
30 | console.error(err);
31 | }
32 |
--------------------------------------------------------------------------------
/scripts/start.js:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { execa } from "execa";
5 | import getPort, { portNumbers } from "get-port";
6 | import { debounce } from "lodash-es";
7 | import { Log, LogLevel, Miniflare } from "miniflare";
8 | import { getArgs, getCloudflareBindings, readWranglerConfig } from "./utils.js";
9 |
10 | const [args, envName = "local"] = getArgs();
11 | const edgeConfig = await readWranglerConfig("edge/wrangler.toml", envName);
12 |
13 | // Build the "edge" (CDN edge endpoint) package in "watch" mode using Vite
14 | const edge = execa(
15 | "yarn",
16 | ["workspace", "edge", "build", "--mode=development", "--watch", ...args],
17 | { stdio: ["inherit", "pipe", "inherit"], env: { FORCE_COLOR: 1 } },
18 | );
19 |
20 | // Start listening for the (re)build events
21 | /** @type {Miniflare} */ let mf;
22 | await new Promise((resolve, reject) => {
23 | edge.then(resolve, reject);
24 | const reload = debounce(() => {
25 | mf?.reload();
26 | resolve();
27 | }, 300);
28 | edge.stdout.on("data", (data) => {
29 | if (!mf) process.stdout.write(data);
30 | if (data.toString().includes("built in")) reload();
31 | });
32 | });
33 |
34 | // Configure Cloudflare dev server
35 | // https://miniflare.dev/get-started/api
36 | const port = await getPort({ port: portNumbers(8080, 8090) });
37 | mf = new Miniflare({
38 | name: edgeConfig.name,
39 | log: new Log(LogLevel.INFO),
40 | scriptPath: "edge/dist/index.js",
41 | sitePath: "app/dist",
42 | wranglerConfigPath: false,
43 | modules: true,
44 | modulesRules: [
45 | { type: "ESModule", include: ["**/*.js"], fallthrough: true },
46 | { type: "Text", include: ["**/*.md"] },
47 | ],
48 | upstream: process.env.APP_ORIGIN,
49 | routes: ["*/*"],
50 | logUnhandledRejections: true,
51 | bindings: getCloudflareBindings("edge/wrangler.toml", envName),
52 | port,
53 | });
54 |
55 | const server = await mf.createServer();
56 | await new Promise((resolve) => server.listen(port, resolve));
57 | process.env.LOCAL_API_ORIGIN = `http://localhost:${port}`;
58 | mf.log.info(`API listening on ${process.env.LOCAL_API_ORIGIN}/`);
59 |
60 | // Launch the front-end app using Vite dev server
61 | execa("yarn", ["workspace", "app", "run", "start", ...args], {
62 | stdio: "inherit",
63 | }).on("close", () => cleanUp());
64 |
65 | async function cleanUp() {
66 | await mf?.dispose();
67 | edge.kill();
68 | setTimeout(() => process.exit(), 500);
69 | }
70 |
--------------------------------------------------------------------------------
/scripts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "../typescript-scripts",
5 | "moduleResolution": "node",
6 | "noEmit": true
7 | },
8 | "include": ["**/*.ts", "**/*.js"]
9 | }
10 |
--------------------------------------------------------------------------------
/scripts/utils.js:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { configDotenv } from "dotenv";
5 | import { template } from "lodash-es";
6 | import { readFileSync } from "node:fs";
7 | import fs from "node:fs/promises";
8 | import { dirname, resolve } from "node:path";
9 | import { URL, fileURLToPath } from "node:url";
10 | import { parse as parseToml } from "toml";
11 | import { $ } from "zx";
12 |
13 | export const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
14 | export const envDir = resolve(rootDir, "env");
15 |
16 | /**
17 | * Get the arguments passed to the script.
18 | *
19 | * @returns {[args: string[], envName: string | undefined]}
20 | */
21 | export function getArgs() {
22 | const args = process.argv.slice(2);
23 | /** @type {String} */
24 | let envName;
25 |
26 | for (let i = 0; i < args.length; i++) {
27 | if (args[i] === "--env") {
28 | envName = args[i + 1];
29 | args.splice(i, 2);
30 | break;
31 | }
32 |
33 | if (args[i]?.startsWith("--env=")) {
34 | envName = args[i].slice(6);
35 | args.splice(i, 1);
36 | break;
37 | }
38 | }
39 |
40 | return [args, envName];
41 | }
42 |
43 | /**
44 | * Load environment variables used in the Cloudflare Worker.
45 | */
46 | export function getCloudflareBindings(file = "wrangler.toml", envName) {
47 | const envDir = fileURLToPath(new URL("..", import.meta.url));
48 |
49 | configDotenv({ path: resolve(envDir, `.env.${envName}.local`) });
50 | configDotenv({ path: resolve(envDir, `.env.local`) });
51 | configDotenv({ path: resolve(envDir, `.env`) });
52 |
53 | let config = parseToml(readFileSync(file, "utf-8"));
54 |
55 | return {
56 | SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
57 | GOOGLE_CLOUD_CREDENTIALS: process.env.GOOGLE_CLOUD_CREDENTIALS,
58 | ...JSON.parse(JSON.stringify(config.vars), (key, value) => {
59 | return typeof value === "string"
60 | ? value.replace(/\$\{?([\w]+)\}?/g, (_, key) => process.env[key])
61 | : value;
62 | }),
63 | };
64 | }
65 |
66 | export async function readWranglerConfig(file, envName = "test") {
67 | const envDir = fileURLToPath(new URL("..", import.meta.url));
68 |
69 | configDotenv({ path: resolve(envDir, `.env.${envName}.local`) });
70 | configDotenv({ path: resolve(envDir, `.env.local`) });
71 | configDotenv({ path: resolve(envDir, `.env`) });
72 |
73 | // Load Wrangler CLI configuration file
74 | let config = parseToml(await fs.readFile(file, "utf-8"));
75 |
76 | // Interpolate environment variables
77 | return JSON.parse(JSON.stringify(config), (key, value) => {
78 | return typeof value === "string"
79 | ? template(value, {
80 | interpolate: /\$\{?([\w]+)\}?/,
81 | })($.env)
82 | : value;
83 | });
84 | }
85 |
--------------------------------------------------------------------------------
/scripts/wrangler.js:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { execa } from "execa";
5 | import path from "node:path";
6 | import { fileURLToPath } from "node:url";
7 | import { $, fs } from "zx";
8 | import { getArgs, readWranglerConfig } from "./utils.js";
9 |
10 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
11 | const [args, envName = "test"] = getArgs();
12 |
13 | // Interpolate and save Wrangler configuration to ./dist/wrangler.json
14 | let config = await readWranglerConfig("./wrangler.toml", envName);
15 | config = JSON.stringify(config, null, " ");
16 | await fs.writeFile("./dist/wrangler.json", config, "utf-8");
17 |
18 | const wranglerBin = await execa("yarn", ["bin", "wrangler"], {
19 | cwd: __dirname,
20 | }).then((p) => p.stdout);
21 |
22 | // Check if there is a secret name, for example:
23 | // > yarn workspace edge wrangler secret put AUTH_KEY
24 | const secret = args.find(
25 | (_, i) => args[i - 2] === "secret" && args[i - 1] === "put",
26 | );
27 |
28 | // Launch Wrangler CLI
29 | const p = execa(
30 | "yarn",
31 | [
32 | "node",
33 | wranglerBin,
34 | "--experimental-json-config",
35 | "-c",
36 | "./dist/wrangler.json",
37 | envName === "prod" ? undefined : `--env=${envName}`,
38 | ...args,
39 | ].filter(Boolean),
40 | {
41 | stdio: secret && $.env[secret] ? ["pipe", "inherit", "inherit"] : "inherit",
42 | },
43 | );
44 |
45 | // Write secret values to stdin (in order to avoid typing them)
46 | if (secret && $.env[secret] && p.stdin) {
47 | p.stdin.write($.env[args[2]]);
48 | p.stdin.end();
49 | }
50 |
51 | // Suppress the error message from the spawned process
52 | await p.catch(() => {
53 | process.exitCode = process.exitCode ?? 1;
54 | return Promise.resolve();
55 | });
56 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2014-present Kriasoft
2 | # SPDX-License-Identifier: MIT
3 |
4 | # Docker image for a Cloud Run service.
5 | #
6 | # https://cloud.google.com/run/docs/container-contract
7 | # https://cloud.google.com/run/docs/quickstarts/build-and-deploy/nodejs
8 | # https://github.com/GoogleCloudPlatform/cloud-run-microservice-template-nodejs/blob/main/Dockerfile
9 |
10 | # Use the official lightweight Node.js image.
11 | # https://hub.docker.com/_/node
12 | FROM node:20.11.0-slim
13 |
14 | # Upgrade OS packages.
15 | RUN apt-get update && apt-get upgrade -y
16 |
17 | # Set environment variables.
18 | ENV NODE_ENV=production
19 |
20 | # Create and change to the app directory.
21 | WORKDIR /usr/src/app
22 |
23 | # Copy application dependency manifests to the container image.
24 | # Copying this separately prevents re-running npm install on every code change.
25 | COPY dist/package.json dist/yarn.lock ./
26 |
27 | # Install dependencies.
28 | RUN corepack enable && yarn config set nodeLinker node-modules && yarn install --immutable
29 |
30 | # Copy compiled code to the container image.
31 | COPY ./dist .
32 |
33 | # Run the web service on container startup.
34 | CMD [ "node", "index.js" ]
35 |
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 | # Application Server
2 |
3 | Node.js application server for web and mobile clients using [tRPC](https://trpc.io/) with HTTP and WebSocket transports.
4 |
5 | ## Scripts
6 |
7 | - `yarn workspace server start` — Start the server in development mode.
8 | - `yarn workspace server build` — Build the server for production.
9 | - `yarn workspace server test` — Run tests.
10 |
--------------------------------------------------------------------------------
/server/app.test.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import supertest from "supertest";
5 | import { describe, expect, it } from "vitest";
6 | import { app } from "./app";
7 |
8 | describe("GET /trpc", () => {
9 | it("returns NOT_FOUND response for unknown requests", async () => {
10 | const res = await supertest(app).get("/trpc");
11 |
12 | expect({
13 | statusCode: res.statusCode,
14 | body: res.body,
15 | }).toEqual({
16 | statusCode: 404,
17 | body: {
18 | error: {
19 | code: -32004,
20 | data: {
21 | code: "NOT_FOUND",
22 | httpStatus: 404,
23 | path: "",
24 | },
25 | message: 'No "query"-procedure on path ""',
26 | },
27 | },
28 | });
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/server/app.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { createExpressMiddleware } from "@trpc/server/adapters/express";
5 | import express from "express";
6 | import { createProxyMiddleware } from "http-proxy-middleware";
7 | import { sessionMiddleware } from "./core/auth";
8 | import { env } from "./core/env";
9 | import { loggerMiddleware } from "./core/logging";
10 | import { createContext } from "./core/trpc";
11 | import { router } from "./routes/index";
12 |
13 | export const app = express();
14 |
15 | app.disable("x-powered-by");
16 | app.set("trust proxy", 1); // Trust first proxy
17 |
18 | app.use(loggerMiddleware);
19 | app.use(sessionMiddleware);
20 |
21 | /**
22 | * tRPC HTTP handler for Express.js.
23 | *
24 | * @see https://trpc.io/docs/getting-started
25 | * @see https://trpc.io/docs/server/adapters/express
26 | */
27 | app.use("/trpc", createExpressMiddleware({ router, createContext }));
28 |
29 | /**
30 | * Proxy auth requests to Google Identity Platform.
31 | *
32 | * @see https://firebase.google.com/docs/auth/web/redirect-best-practices
33 | */
34 | app.use(
35 | createProxyMiddleware("/__", {
36 | target: `https://${env.GOOGLE_CLOUD_PROJECT}.firebaseapp.com`,
37 | changeOrigin: true,
38 | logLevel: "warn",
39 | }),
40 | );
41 |
42 | /**
43 | * Proxy static assets to Google Cloud Storage.
44 | */
45 | app.use(
46 | createProxyMiddleware("/", {
47 | target: "https://c.storage.googleapis.com",
48 | changeOrigin: true,
49 | logLevel: "warn",
50 | onProxyReq(proxyReq) {
51 | proxyReq.setHeader("host", env.APP_STORAGE_BUCKET);
52 | },
53 | }),
54 | );
55 |
--------------------------------------------------------------------------------
/server/core/auth.test.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import express from "express";
5 | import supertest from "supertest";
6 | import { describe, expect, it } from "vitest";
7 | import { fetchCertificates, sessionMiddleware } from "./auth";
8 | import { env } from "./env";
9 |
10 | describe("fetchCertificates()", () => {
11 | it("should fetch certificates from Google", async () => {
12 | const certs = await fetchCertificates();
13 | expect(certs).toEqual(expect.any(Object));
14 | expect(Object.values(certs)).toEqual(
15 | expect.arrayContaining([
16 | expect.stringMatching(/^-----BEGIN CERTIFICATE-----/),
17 | ]),
18 | );
19 | });
20 | });
21 |
22 | describe("sessionMiddleware()", () => {
23 | it.skip("should verify a valid ID token", async () => {
24 | const app = express();
25 | app.get("/", sessionMiddleware, (req, res) => {
26 | res.type("json");
27 | res.send(JSON.stringify(req.token));
28 | });
29 |
30 | const idToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImQxNjg5NDE1ZWMyM2EzMzdlMmJiYWE1ZTNlNjhiNjZkYzk5MzY4ODQiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiS3JpYXNvZnQiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jS0hBRVhvRXpJMnpoMWNpN3lna0FVZXFiRWppQ1ZxUE96VkZQYnZmUGtXcGc9czk2LWMiLCJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20va3JpYXNvZnQiLCJhdWQiOiJrcmlhc29mdCIsImF1dGhfdGltZSI6MTcwNDU0MjYyOCwidXNlcl9pZCI6IkpRdWxWbjhaMUhZelRIR0J5YnhlcndGRUhsNzIiLCJzdWIiOiJKUXVsVm44WjFIWXpUSEdCeWJ4ZXJ3RkVIbDcyIiwiaWF0IjoxNzA0NTQyNjI4LCJleHAiOjE3MDQ1NDYyMjgsImVtYWlsIjoia3JpYXNvZnRAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZ29vZ2xlLmNvbSI6WyIxMDI2NTQwODA3NzUxNTYyOTI2ODYiXSwiZW1haWwiOlsia3JpYXNvZnRAZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoiZ29vZ2xlLmNvbSJ9fQ.E1sOoIeLH0269m4K4DXfXOJk97cxc8h3D62u3q9Kqyk0AsmwQfKURRl34IiOtNEzizjesLex6EHSetFr8kS1GSgVrW6yxHowmJZCY8tsgSWfunZ7Vj8l1kG4y0iM7hdFw3t0dhilK8-vDlKpLeRfLVHG8qgt46qI7Rxmdb928llJoa7H6NuuS5heavNJadLfiYItJyUq7i5kjys6-WfndQQcRb7kTt07arHb_1w2jtZnyjZE_S3ErZcIgwnE9M_gqXZ4y1MucpGPR2_nHzicRBBYOMwZDVG7Y0tI9IWRaTyTF3Psd7XKisE6GorZ_X1cDwkaT5ffoXZ1tkBOjeMjfw"; // prettier-ignore
31 | const res = await supertest(app).get("/").auth(idToken, { type: "bearer" });
32 |
33 | expect({ status: res.status, body: res.body }).toEqual({
34 | status: 200,
35 | body: expect.objectContaining({
36 | name: expect.any(String),
37 | picture: expect.any(String),
38 | iss: `https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`,
39 | aud: env.GOOGLE_CLOUD_PROJECT,
40 | auth_time: expect.any(Number),
41 | sub: expect.any(String),
42 | iat: expect.any(Number),
43 | exp: expect.any(Number),
44 | email: expect.any(String),
45 | email_verified: true,
46 | firebase: expect.objectContaining({
47 | sign_in_provider: "google.com",
48 | }),
49 | }),
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/server/core/auth.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { NextFunction, Request, Response } from "express";
5 | import {
6 | Certificates,
7 | GoogleAuth,
8 | IdTokenClient,
9 | TokenPayload,
10 | } from "google-auth-library";
11 | import { got } from "got";
12 | import { env } from "./env";
13 |
14 | export const auth = new GoogleAuth({
15 | scopes: ["https://www.googleapis.com/auth/cloud-platform"],
16 | });
17 |
18 | const certificatesURL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com"; // prettier-ignore
19 | const certificatesCache = new Map();
20 |
21 | /**
22 | * Fetches the latest Google Cloud Identity Platform certificates.
23 | */
24 | export function fetchCertificates(options?: { signal: AbortSignal }) {
25 | return got.get(certificatesURL, {
26 | cache: certificatesCache,
27 | resolveBodyOnly: true,
28 | responseType: "json",
29 | signal: options?.signal,
30 | });
31 | }
32 |
33 | // Refresh certificates every 6 hours.
34 | const cleanup = (() => {
35 | const ac = new AbortController();
36 | const int = setInterval(() => fetchCertificates(), 2.16e7);
37 | fetchCertificates({ signal: ac.signal });
38 | return () => {
39 | clearInterval(int);
40 | ac.abort();
41 | };
42 | })();
43 |
44 | process.on("SIGTERM", cleanup);
45 | process.on("SIGINT", cleanup);
46 |
47 | const idTokenClients = new Map();
48 |
49 | /**
50 | * Express middleware that verifies that the request has a valid Firebase ID
51 | * token attached, and adds the decoded token to `req.token`.
52 | */
53 | export async function sessionMiddleware(
54 | req: Request,
55 | res: Response,
56 | next: NextFunction,
57 | ) {
58 | try {
59 | req.token = null;
60 | const idToken = req.headers.authorization?.replace(/^Bearer /i, "");
61 |
62 | if (idToken) {
63 | const certificatesPromise = fetchCertificates();
64 | const audience = env.GOOGLE_CLOUD_PROJECT;
65 | let idTokenClient = idTokenClients.get(audience);
66 |
67 | if (!idTokenClient) {
68 | idTokenClient = await auth.getIdTokenClient(audience);
69 | idTokenClients.set(audience, idTokenClient);
70 | }
71 |
72 | const ticket = await idTokenClient.verifySignedJwtWithCertsAsync(
73 | idToken,
74 | await certificatesPromise,
75 | audience,
76 | [`https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`],
77 | );
78 |
79 | const token = ticket.getPayload();
80 |
81 | if (token) {
82 | if ("user_id" in token) delete token.user_id;
83 | Object.assign(token, { uid: token.sub });
84 | req.token = token as DecodedIdToken;
85 | }
86 | }
87 |
88 | next();
89 | } catch (err) {
90 | req.log?.warn(err);
91 | next();
92 | }
93 | }
94 |
95 | // #region Types
96 |
97 | /**
98 | * Interface representing a decoded Firebase ID token, returned from the
99 | * {@link verifyIdToken} method.
100 | *
101 | * Firebase ID tokens are OpenID Connect spec-compliant JSON Web Tokens (JWTs).
102 | * See the
103 | * [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken)
104 | * for more information about the specific properties below.
105 | */
106 | export interface DecodedIdToken extends TokenPayload {
107 | /**
108 | * Time, in seconds since the Unix epoch, when the end-user authentication
109 | * occurred.
110 | *
111 | * This value is not set when this particular ID token was created, but when the
112 | * user initially logged in to this session. In a single session, the Firebase
113 | * SDKs will refresh a user's ID tokens every hour. Each ID token will have a
114 | * different [`iat`](#iat) value, but the same `auth_time` value.
115 | */
116 | auth_time: number;
117 |
118 | /**
119 | * Information about the sign in event, including which sign in provider was
120 | * used and provider-specific identity details.
121 | *
122 | * This data is provided by the Firebase Authentication service and is a
123 | * reserved claim in the ID token.
124 | */
125 | firebase: {
126 | /**
127 | * Provider-specific identity details corresponding
128 | * to the provider used to sign in the user.
129 | */
130 | identities: {
131 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
132 | [key: string]: any;
133 | };
134 |
135 | /**
136 | * The ID of the provider used to sign in the user.
137 | * One of `"anonymous"`, `"password"`, `"facebook.com"`, `"github.com"`,
138 | * `"google.com"`, `"twitter.com"`, `"apple.com"`, `"microsoft.com"`,
139 | * `"yahoo.com"`, `"phone"`, `"playgames.google.com"`, `"gc.apple.com"`,
140 | * or `"custom"`.
141 | *
142 | * Additional Identity Platform provider IDs include `"linkedin.com"`,
143 | * OIDC and SAML identity providers prefixed with `"saml."` and `"oidc."`
144 | * respectively.
145 | */
146 | sign_in_provider: string;
147 |
148 | /**
149 | * The type identifier or `factorId` of the second factor, provided the
150 | * ID token was obtained from a multi-factor authenticated user.
151 | * For phone, this is `"phone"`.
152 | */
153 | sign_in_second_factor?: string;
154 |
155 | /**
156 | * The `uid` of the second factor used to sign in, provided the
157 | * ID token was obtained from a multi-factor authenticated user.
158 | */
159 | second_factor_identifier?: string;
160 |
161 | /**
162 | * The ID of the tenant the user belongs to, if available.
163 | */
164 | tenant?: string;
165 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
166 | [key: string]: any;
167 | };
168 |
169 | /**
170 | * The phone number of the user to whom the ID token belongs, if available.
171 | */
172 | phone_number?: string;
173 |
174 | /**
175 | * The `uid` corresponding to the user who the ID token belonged to.
176 | *
177 | * This value is not actually in the JWT token claims itself. It is added as a
178 | * convenience, and is set as the value of the [`sub`](#sub) property.
179 | */
180 | uid: string;
181 |
182 | /**
183 | * Indicates whether or not the user is an admin.
184 | */
185 | admin?: boolean;
186 | }
187 |
188 | // #endregion
189 |
--------------------------------------------------------------------------------
/server/core/env.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { cleanEnv, str } from "envalid";
5 |
6 | /**
7 | * Environment variables that has been validated and sanitized.
8 | *
9 | * @see https://github.com/ilyakaznacheev/cleanenv#readme
10 | */
11 | export const env = cleanEnv(process.env, {
12 | VERSION: str({ default: "latest" }),
13 |
14 | APP_STORAGE_BUCKET: str(),
15 |
16 | GOOGLE_CLOUD_PROJECT: str(),
17 | GOOGLE_CLOUD_DATABASE: str(),
18 |
19 | OPENAI_ORGANIZATION: str(),
20 | OPENAI_API_KEY: str(),
21 | });
22 |
--------------------------------------------------------------------------------
/server/core/firestore.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Firestore } from "@google-cloud/firestore";
5 | import { env } from "./env";
6 |
7 | let db: Firestore | undefined;
8 |
9 | export function getFirestore() {
10 | if (!db) {
11 | db = new Firestore({
12 | projectId: env.GOOGLE_CLOUD_PROJECT,
13 | databaseId: env.GOOGLE_CLOUD_DATABASE,
14 | });
15 | }
16 |
17 | return db;
18 | }
19 |
--------------------------------------------------------------------------------
/server/core/logging.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Request, Response } from "express";
5 | import { pino } from "pino";
6 | import { pinoHttp } from "pino-http";
7 | import { env } from "./env";
8 |
9 | /**
10 | * Low overhead Node.js logger.
11 | *
12 | * @see https://github.com/pinojs/pino
13 | */
14 | export const logger = pino({
15 | // Custom formatter to set the "severity" property in the JSON payload
16 | // to the log level to be automatically parsed.
17 | // https://cloud.google.com/run/docs/logging#special-fields
18 | formatters: {
19 | level(label) {
20 | return { severity: label };
21 | },
22 | },
23 | transport: {
24 | // Enable pretty printing in development.
25 | // https://github.com/pinojs/pino-pretty#readme
26 | target: env.isProduction ? "pino/file" : "pino-pretty",
27 | options: {
28 | ...(!env.isProduction && { colorize: true }),
29 | ignore: env.isProduction
30 | ? "pid,hostname"
31 | : "pid,hostname,req.headers,req.remoteAddress,req.remotePort,res.headers",
32 | },
33 | },
34 | });
35 |
36 | /**
37 | * Creates a request-based logger with trace ID field for logging correlation.
38 | *
39 | * @see https://cloud.google.com/run/docs/logging#correlate-logs
40 | */
41 | export const loggerMiddleware = pinoHttp({
42 | logger,
43 | customProps(req) {
44 | const traceHeader = req.header("X-Cloud-Trace-Context");
45 |
46 | let trace;
47 |
48 | if (traceHeader) {
49 | const [traceId] = traceHeader.split("/");
50 | trace = `projects/${env.GOOGLE_CLOUD_PROJECT}/traces/${traceId}`;
51 | }
52 |
53 | return {
54 | "logging.googleapis.com/trace": trace,
55 | };
56 | },
57 | redact: {
58 | paths: ["req.headers.authorization", "req.headers.cookie"],
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/server/core/openai.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { OpenAI } from "openai";
5 | import { env } from "./env";
6 |
7 | /**
8 | * OpenAI API client.
9 | *
10 | * @see https://github.com/openai/openai-node#readme
11 | */
12 | export const openai = new OpenAI({
13 | organization: env.OPENAI_ORGANIZATION,
14 | apiKey: env.OPENAI_API_KEY,
15 | });
16 |
--------------------------------------------------------------------------------
/server/core/trpc.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Firestore } from "@google-cloud/firestore";
5 | import { TRPCError, initTRPC } from "@trpc/server";
6 | import { CreateExpressContextOptions } from "@trpc/server/adapters/express";
7 | import { Logger } from "pino";
8 | import { SetNonNullable } from "type-fest";
9 | import { ZodError } from "zod";
10 | import { DecodedIdToken } from "./auth";
11 | import { env } from "./env";
12 | import { getFirestore } from "./firestore";
13 | import { logger } from "./logging";
14 |
15 | /**
16 | * tRPC instance.
17 | *
18 | * @see https://trpc.io/docs/quickstart
19 | */
20 | export const t = initTRPC.context().create({
21 | isDev: env.isDev,
22 |
23 | // https://trpc.io/docs/server/error-formatting
24 | errorFormatter(opts) {
25 | const { shape, error } = opts;
26 | return {
27 | ...shape,
28 | data: {
29 | ...shape.data,
30 | ...(error.code === "BAD_REQUEST" &&
31 | error.cause instanceof ZodError && {
32 | zodError: error.cause.flatten(),
33 | }),
34 | },
35 | };
36 | },
37 | });
38 |
39 | /**
40 | * Creates a tRPC context for an incoming HTTP request.
41 | */
42 | export async function createContext(
43 | ctx: CreateExpressContextOptions,
44 | ): Promise {
45 | return new HttpContext(getFirestore(), ctx.req.log, ctx.req.token);
46 | }
47 |
48 | /**
49 | * Creates a tRPC context for an incoming WebSocket request.
50 | */
51 | export async function createWsContext(): Promise {
52 | return new WsContext(getFirestore(), logger);
53 | }
54 |
55 | class HttpContext {
56 | constructor(
57 | readonly db: Firestore,
58 | readonly log: Logger,
59 | readonly token: DecodedIdToken | null,
60 | ) {}
61 | }
62 |
63 | class WsContext {
64 | constructor(
65 | readonly db: Firestore,
66 | readonly log: Logger,
67 | ) {}
68 |
69 | get token(): DecodedIdToken | null {
70 | throw new Error("ID token is not available in WebSocket context.");
71 | }
72 | }
73 |
74 | /**
75 | * Ensures that the user is authenticated.
76 | */
77 | export const authorize = t.middleware((opts) => {
78 | if (!opts.ctx.token) {
79 | throw new TRPCError({ code: "UNAUTHORIZED" });
80 | }
81 |
82 | return opts.next({
83 | ...opts,
84 | ctx: opts.ctx as SetNonNullable,
85 | });
86 | });
87 |
88 | /**
89 | * Ensures that the user is an admin.
90 | */
91 | export const authorizeAdmin = authorize.unstable_pipe((opts) => {
92 | if (!opts.ctx.token.admin) {
93 | throw new TRPCError({ code: "FORBIDDEN" });
94 | }
95 |
96 | return opts.next(opts);
97 | });
98 |
99 | /**
100 | * tRPC context.
101 | */
102 | export type Context = HttpContext;
103 |
--------------------------------------------------------------------------------
/server/core/utils.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { customAlphabet, urlAlphabet } from "nanoid";
5 |
6 | const shortIdAlphabet = urlAlphabet.replace(/[-_]/g, "");
7 |
8 | /**
9 | * Generates secure unique ID using URL friendly characters.
10 | *
11 | * @see https://github.com/ai/nanoid#readme
12 | */
13 | export const newId = customAlphabet(shortIdAlphabet, 10);
14 |
--------------------------------------------------------------------------------
/server/global.d.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import "express";
5 | import { DecodedIdToken } from "./core/auth";
6 |
7 | declare global {
8 | namespace Express {
9 | interface Request {
10 | token: DecodedIdToken | null;
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { applyWSSHandler } from "@trpc/server/adapters/ws";
5 | import { WebSocketServer } from "ws";
6 | import { app } from "./app";
7 | import { logger } from "./core/logging";
8 | import { createWsContext as createContext } from "./core/trpc";
9 | import { router } from "./routes";
10 |
11 | // Detect if running in Google Cloud environment
12 | const isCloudRun = !!process.env.K_SERVICE;
13 |
14 | /**
15 | * Starts the HTTP and WebSocket servers.
16 | */
17 | export function listen(port: number) {
18 | const server = app.listen(port, () => {
19 | logger.info(`API listening on ${port}`);
20 | });
21 |
22 | const wss = new WebSocketServer({ server, path: "/trpc" });
23 | const handler = applyWSSHandler({ wss, router, createContext });
24 |
25 | wss.on("connection", (ws) => {
26 | logger.info({ clients: wss.clients.size }, "wss:connection");
27 | ws.once("close", () => {
28 | logger.info({ clients: wss.clients.size }, "wss:close");
29 | });
30 | });
31 |
32 | return function dispose(cb?: () => void) {
33 | handler.broadcastReconnectNotification();
34 | wss.close((err) => {
35 | if (err) logger.error(err);
36 | if (isCloudRun) logger.info("WebSocket server closed");
37 | server.close((err) => {
38 | if (err) logger.error(err);
39 | if (isCloudRun) logger.info("HTTP server closed");
40 | logger.flush((err) => {
41 | if (err) console.error(err);
42 | if (isCloudRun) {
43 | process.exit(0);
44 | } else {
45 | cb?.();
46 | }
47 | });
48 | });
49 | });
50 | };
51 | }
52 |
53 | if (process.env.PORT && process.env.K_SERVICE?.startsWith("server")) {
54 | const port = parseInt(process.env.PORT);
55 | const dispose = listen(port);
56 |
57 | /* eslint-disable-next-line no-inner-declarations */
58 | function handleClose(code: NodeJS.Signals) {
59 | logger.info(`${code} signal received`);
60 | dispose();
61 | }
62 |
63 | process.on("SIGINT", handleClose);
64 | process.on("SIGTERM", handleClose);
65 | }
66 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | ".": {
8 | "types": "./types.ts"
9 | },
10 | "./package.json": "./package.json"
11 | },
12 | "scripts": {
13 | "start": "vite-node --watch ./start.ts",
14 | "build": "vite build && yarn node ../scripts/bundle-yarn.js",
15 | "test": "vitest"
16 | },
17 | "dependencies": {
18 | "@google-cloud/firestore": "^7.3.0",
19 | "@googleapis/identitytoolkit": "^8.0.1",
20 | "@trpc/server": "^10.45.1",
21 | "db": "workspace:*",
22 | "envalid": "^8.0.0",
23 | "express": "^4.18.2",
24 | "google-auth-library": "^9.6.3",
25 | "got": "^13.0.0",
26 | "http-errors": "^2.0.0",
27 | "http-proxy-middleware": "^2.0.6",
28 | "nanoid": "^5.0.5",
29 | "openai": "^4.28.0",
30 | "pino": "^8.18.0",
31 | "pino-http": "^9.0.0",
32 | "pino-pretty": "^10.3.1",
33 | "type-fest": "^4.10.2",
34 | "ws": "^8.16.0",
35 | "zod": "^3.22.4"
36 | },
37 | "devDependencies": {
38 | "@types/express": "^4.17.21",
39 | "@types/http-errors": "^2.0.4",
40 | "@types/node": "^20.11.18",
41 | "@types/supertest": "^6.0.2",
42 | "@types/ws": "^8.5.10",
43 | "envars": "^1.0.2",
44 | "get-port": "^7.0.0",
45 | "supertest": "^6.3.4",
46 | "type-fest": "^4.10.2",
47 | "typescript": "~5.3.3",
48 | "vite": "~5.1.2",
49 | "vite-node": "~1.2.2",
50 | "vitest": "~1.2.2"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/server/routes/index.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { t } from "../core/trpc";
5 | import { workspace } from "./workspace";
6 |
7 | /**
8 | * The root tRPC router.
9 | * @see https://trpc.io/docs/quickstart
10 | */
11 | export const router = t.router({
12 | workspace,
13 | });
14 |
15 | export type AppRouter = typeof router;
16 |
--------------------------------------------------------------------------------
/server/routes/workspace.test.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { TRPCError } from "@trpc/server";
5 | import { testWorkspaces } from "db";
6 | import { describe, expect, it } from "vitest";
7 | import { createClient } from "../test/context";
8 | import * as router from "./workspace";
9 |
10 | describe("workspace.update()", () => {
11 | it("requires authentication", async () => {
12 | const [client] = createClient(router.workspace);
13 | const action = client.update({
14 | id: "xxxxxxxxxx",
15 | name: "My Workspace",
16 | });
17 |
18 | await expect(action).rejects.toThrowError(
19 | new TRPCError({ code: "UNAUTHORIZED" }),
20 | );
21 | });
22 |
23 | it("workspace must exist", async () => {
24 | const [client] = createClient(router.workspace, { user: "erika" });
25 | const action = client.update({
26 | id: "xxxxxxxxxx", // Non-existent workspace ID
27 | name: "My Workspace",
28 | });
29 |
30 | await expect(action).rejects.toThrowError(
31 | new TRPCError({ code: "NOT_FOUND" }),
32 | );
33 | });
34 |
35 | it("workspace must belong to the user", async () => {
36 | const [client, ctx] = createClient(router.workspace, { user: "erika" });
37 | const id = testWorkspaces.find((x) => x.ownerId !== ctx.token?.uid)?.id;
38 | expect(id).toEqual(expect.any(String));
39 |
40 | const action = client.update({
41 | id: id!, // Workspace ID that belongs to another user
42 | name: "My Workspace",
43 | });
44 |
45 | await expect(action).rejects.toThrowError(
46 | new TRPCError({ code: "NOT_FOUND" }),
47 | );
48 | });
49 |
50 | it("updates the workspace", async () => {
51 | const [client, ctx] = createClient(router.workspace, { user: "erika" });
52 | const id = testWorkspaces.find((x) => x.ownerId === ctx.token?.uid)?.id;
53 | expect(id).toEqual(expect.any(String));
54 |
55 | await client.update({ id: id!, name: "My Workspace" });
56 | const doc1 = await ctx.db.doc(`workspace/${id}`).get();
57 | expect(doc1.data()?.name).toBe("My Workspace");
58 |
59 | await client.update({ id: id!, name: "Personal workspace" });
60 | const doc2 = await ctx.db.doc(`workspace/${id}`).get();
61 | expect(doc2.data()?.name).toBe("Personal workspace");
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/server/routes/workspace.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Timestamp } from "@google-cloud/firestore";
5 | import { TRPCError } from "@trpc/server";
6 | import { z } from "zod";
7 | import { authorize, t } from "../core/trpc";
8 |
9 | /**
10 | * Workspace API.
11 | */
12 | export const workspace = t.router({
13 | /**
14 | * Updates the workspace.
15 | */
16 | update: t.procedure
17 | .use(authorize)
18 | .input(
19 | z.object({
20 | id: z.string(),
21 | name: z.string().max(100),
22 | }),
23 | )
24 | .query(async ({ input, ctx }) => {
25 | const { db } = ctx;
26 | const doc = await db.doc(`workspace/${input.id}`).get();
27 |
28 | if (!doc.exists || doc.data()?.ownerId !== ctx.token.uid) {
29 | throw new TRPCError({ code: "NOT_FOUND" });
30 | }
31 |
32 | await doc.ref.update({
33 | name: input.name,
34 | updated: Timestamp.now(),
35 | });
36 | }),
37 | });
38 |
39 | export type WorkspaceRouter = typeof workspace;
40 |
--------------------------------------------------------------------------------
/server/start.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import getPort, { portNumbers } from "get-port";
5 | import { listen } from "./index";
6 |
7 | const port = await getPort({ port: portNumbers(8080, 9000) });
8 |
9 | let dispose = listen(port);
10 |
11 | if (import.meta.hot) {
12 | import.meta.hot.accept("/index.ts", () => {
13 | dispose(() => {
14 | import("./index").then(({ listen }) => {
15 | dispose = listen(port);
16 | });
17 | });
18 | });
19 | }
20 |
21 | function cleanUp() {
22 | dispose(() => process.exit());
23 | }
24 |
25 | process.on("SIGINT", cleanUp);
26 | process.on("SIGTERM", cleanUp);
27 |
--------------------------------------------------------------------------------
/server/test/context.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Firestore } from "@google-cloud/firestore";
5 | import {
6 | AnyRootConfig,
7 | AnyRouterDef,
8 | Router,
9 | createCallerFactory,
10 | } from "@trpc/server";
11 | import { testUsers } from "db";
12 | import { IdTokenClient } from "google-auth-library";
13 | import { auth } from "../core/auth";
14 | import { env } from "../core/env";
15 | import { getFirestore } from "../core/firestore";
16 | import { logger } from "../core/logging";
17 | import { Context } from "../core/trpc";
18 |
19 | let idTokenClient: IdTokenClient | undefined;
20 |
21 | export async function getIdToken(audience: string) {
22 | idTokenClient = idTokenClient ?? (await auth.getIdTokenClient(audience));
23 | return await idTokenClient.idTokenProvider.fetchIdToken(audience);
24 | }
25 |
26 | /**
27 | * Create a tRPC context for unit testing.
28 | */
29 | export function createContext(options?: ContextOptions): Context {
30 | const user = testUsers.find((u) => u.screenName === options?.user);
31 | const now = Math.floor(Date.now() / 1000);
32 |
33 | if (options?.user && !user) {
34 | throw new Error(`User not found: ${options.user}`);
35 | }
36 |
37 | return {
38 | db: options?.db ?? getFirestore(),
39 | log: logger,
40 | token: user
41 | ? {
42 | uid: user.localId,
43 | sub: user.localId,
44 | email: user.email,
45 | email_verified: user.emailVerified,
46 | aud: env.GOOGLE_CLOUD_PROJECT,
47 | iss: `https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`,
48 | iat: now,
49 | exp: now + 3600,
50 | auth_time: parseInt(user.lastLoginAt!, 10),
51 | firebase: {
52 | identities: {},
53 | sign_in_provider: "google.com",
54 | },
55 | ...(user.customAttributes && JSON.parse(user.customAttributes)),
56 | }
57 | : null,
58 | };
59 | }
60 |
61 | export function createClient<
62 | TRouter extends Router>,
63 | >(router: TRouter, options?: ContextOptions) {
64 | const createCaller = createCallerFactory();
65 | const ctx = createContext(options);
66 | const client = createCaller(router)(ctx);
67 | return [client, ctx] as const;
68 | }
69 |
70 | // #region Types
71 |
72 | type ContextOptions = {
73 | /**
74 | * The user to impersonate. Use `dan` if you need admin permissions.
75 | */
76 | user?: "erika" | "ryan" | "marian" | "kurt" | "dan";
77 | db?: Firestore;
78 | };
79 |
80 | // #endregion Types
81 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "emitDeclarationOnly": true,
6 | "module": "ESNext",
7 | "moduleResolution": "Bundler",
8 | "noEmit": false,
9 | "outDir": "../.cache/ts-server",
10 | "types": ["node", "vite/client"]
11 | },
12 | "include": ["**/*.ts", "**/*.json"],
13 | "exclude": ["**/dist/**/*", "**/node_modules/**/*"],
14 | "references": [{ "path": "../db" }]
15 | }
16 |
--------------------------------------------------------------------------------
/server/types.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | export type { AppRouter } from "./routes/index";
5 |
--------------------------------------------------------------------------------
/server/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { loadEnv } from "envars";
5 | import { defineConfig } from "vitest/config";
6 |
7 | /**
8 | * Vite configuration for the server-side bundle.
9 | *
10 | * @see https://vitejs.dev/config/
11 | */
12 | export default defineConfig(async ({ mode }) => {
13 | await loadEnv(mode, {
14 | root: "..",
15 | schema: "./core/env.ts",
16 | mergeTo: process.env,
17 | });
18 |
19 | return {
20 | cacheDir: "../.cache/vite-server",
21 |
22 | build: {
23 | ssr: "./index.ts",
24 | sourcemap: true,
25 | },
26 |
27 | ssr: {
28 | ...(mode === "production" && {
29 | noExternal: ["http-errors"],
30 | }),
31 | },
32 |
33 | test: {
34 | environment: "node",
35 | testTimeout: 20000,
36 | },
37 | };
38 | });
39 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26 |
27 | /* Modules */
28 | "module": "ESNext", /* Specify what module code is generated. */
29 | // "rootDir": "./", /* Specify the root folder within your source files. */
30 | "moduleResolution": "Bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
42 | "resolveJsonModule": true, /* Enable importing .json files. */
43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
45 |
46 | /* JavaScript Support */
47 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
50 |
51 | /* Emit */
52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
58 | // "outDir": "./", /* Specify an output folder for all emitted files. */
59 | // "removeComments": true, /* Disable emitting comments. */
60 | // "noEmit": true, /* Disable emitting files from a compilation. */
61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
68 | // "newLine": "crlf", /* Set the newline character for emitting files. */
69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
75 |
76 | /* Interop Constraints */
77 | "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
83 |
84 | /* Type Checking */
85 | "strict": true, /* Enable all strict type-checking options. */
86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
90 | "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
100 | "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
104 |
105 | /* Completeness */
106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./app" },
5 | { "path": "./db" },
6 | { "path": "./edge" },
7 | { "path": "./scripts" },
8 | { "path": "./server" }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { defineConfig } from "vitest/config";
5 |
6 | /**
7 | * Vitest configuration.
8 | *
9 | * @see https://vitest.dev/config/
10 | */
11 | export default defineConfig({
12 | test: {
13 | cache: {
14 | dir: "./.cache/vitest",
15 | },
16 | },
17 | });
18 |
--------------------------------------------------------------------------------
/vitest.workspace.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { defineWorkspace } from "vitest/config";
5 | import { workspaces } from "./package.json";
6 |
7 | /**
8 | * Inline Vitest configuration for all workspaces.
9 | *
10 | * @see https://vitest.dev/guide/workspace
11 | */
12 | export default defineWorkspace(
13 | workspaces
14 | .filter((name) => !["scripts"].includes(name))
15 | .map((name) => ({
16 | extends: `./${name}/vite.config.ts`,
17 | test: {
18 | name,
19 | root: `./${name}`,
20 | },
21 | })),
22 | );
23 |
--------------------------------------------------------------------------------