@trypattern/sdk 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +17 -7
- package/dist/api.js +34 -8
- package/dist/app.d.ts +12 -3
- package/dist/app.js +49 -7
- package/dist/appInfo.d.ts +35 -0
- package/dist/appInfo.js +67 -0
- package/dist/appTitle.d.ts +6 -0
- package/dist/appTitle.js +3 -0
- package/dist/auth/sessionApi.js +2 -1
- package/dist/builtins/AppSettings.d.ts +12 -0
- package/dist/builtins/AppSettings.js +31 -0
- package/dist/builtins/Toasts.d.ts +1 -1
- package/dist/builtins/Toasts.js +2 -1
- package/dist/cli/app.js +91 -14
- package/dist/cli/publishing.d.ts +21 -0
- package/dist/cli/publishing.js +502 -0
- package/dist/clock.d.ts +6 -0
- package/dist/clock.js +31 -0
- package/dist/components/AppFrame.js +3 -3
- package/dist/components/Button.d.ts +2 -1
- package/dist/components/Button.js +6 -3
- package/dist/components/CopyButton.d.ts +11 -0
- package/dist/components/CopyButton.js +16 -0
- package/dist/components/Divider.d.ts +2 -1
- package/dist/components/Divider.js +19 -4
- package/dist/components/IconButton.js +2 -2
- package/dist/components/Link.d.ts +8 -3
- package/dist/components/Link.js +11 -2
- package/dist/components/ListRow.js +2 -2
- package/dist/components/Menu.js +2 -2
- package/dist/components/RadioGroup.d.ts +18 -0
- package/dist/components/RadioGroup.js +85 -0
- package/dist/components/SegmentedControl.js +2 -2
- package/dist/components/Sidebar.js +2 -3
- package/dist/components/SignInScreen.js +3 -3
- package/dist/components/Switch.js +2 -1
- package/dist/components/controlLook.d.ts +10 -0
- package/dist/components/controlLook.js +14 -1
- package/dist/components/icons.d.ts +4 -3
- package/dist/components/icons.js +9 -3
- package/dist/components/interaction.d.ts +1 -1
- package/dist/flows/accessFlows.js +19 -3
- package/dist/flows/browser.d.ts +3 -1
- package/dist/flows/browser.js +6 -3
- package/dist/flows/flowBrowser.d.ts +3 -1
- package/dist/flows/flowBrowser.js +4 -2
- package/dist/flows/flows.d.ts +6 -0
- package/dist/flows/flows.js +32 -5
- package/dist/hooks/appContext.d.ts +2 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -2
- package/dist/publishWire.d.ts +271 -0
- package/dist/publishWire.js +168 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +2 -0
- package/dist/registered.d.ts +6 -0
- package/dist/registered.js +2 -0
- package/dist/rules/checkAllFiles.js +10 -2
- package/dist/rules/lintPlugin.js +2 -0
- package/dist/rules/messages.d.ts +2 -2
- package/dist/rules/messages.js +2 -2
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +1 -0
- package/dist/server/auth.js +8 -2
- package/dist/server/createServer.d.ts +48 -3
- package/dist/server/createServer.js +25 -5
- package/dist/server/env.d.ts +2 -2
- package/dist/server/index.d.ts +6 -1
- package/dist/server/index.js +5 -1
- package/dist/server/services.d.ts +9 -0
- package/dist/server/services.js +47 -0
- package/dist/server/session.d.ts +1 -0
- package/dist/server/session.js +11 -0
- package/dist/server/settings.d.ts +49 -0
- package/dist/server/settings.js +25 -0
- package/dist/server/signInWire.d.ts +1 -0
- package/dist/server/signInWire.js +2 -1
- package/dist/serviceClients.d.ts +5 -0
- package/dist/serviceClients.js +70 -0
- package/dist/services.d.ts +53 -0
- package/dist/services.js +30 -0
- package/dist/start.js +30 -6
- package/dist/store.d.ts +5 -3
- package/dist/store.js +13 -3
- package/dist/table.d.ts +1 -1
- package/dist/vite/localWorker.d.ts +1 -1
- package/dist/vite/localWorker.js +5 -4
- package/dist/vite/projectFile.d.ts +9 -0
- package/dist/vite/projectFile.js +17 -0
- package/dist/vite/vite.d.ts +2 -1
- package/dist/vite/vite.js +10 -3
- package/package.json +5 -1
- package/presets/register.d.ts +2 -0
|
@@ -332,6 +332,7 @@ const SRC_ENTRY_FILES = new Set([
|
|
|
332
332
|
'App.ts',
|
|
333
333
|
'models/index.ts',
|
|
334
334
|
'stores/index.ts',
|
|
335
|
+
'services/index.ts',
|
|
335
336
|
'actions/index.ts',
|
|
336
337
|
'main.tsx',
|
|
337
338
|
'routes.tsx',
|
|
@@ -345,6 +346,7 @@ const SRC_FOLDER_PATTERNS = [
|
|
|
345
346
|
/^layouts\/[A-Z][A-Za-z]*Layout\.tsx$/,
|
|
346
347
|
/^models\/[A-Z][A-Za-z]*\.ts$/,
|
|
347
348
|
/^stores\/[A-Z][A-Za-z]*\.ts$/,
|
|
349
|
+
/^services\/[a-z][A-Za-z]*\.ts$/,
|
|
348
350
|
/^actions\/[a-z][A-Za-z]*\.ts$/
|
|
349
351
|
];
|
|
350
352
|
const PREFIXED_FOLDER_FILE = /^(components)\/([A-Z][A-Za-z]*)\/([A-Z][A-Za-z]*)\.tsx?$/;
|
|
@@ -362,6 +364,7 @@ const MODULE_FILE = /\.(ts|tsx|mts|cts)$/;
|
|
|
362
364
|
const CLASS_HOMES = [
|
|
363
365
|
{ pattern: /\bextends\s+Model\s*\(/, folder: 'models/' },
|
|
364
366
|
{ pattern: /\bextends\s+Store\s*\(/, folder: 'stores/' },
|
|
367
|
+
{ pattern: /\bdefineServices\s*\(/, folder: 'services/' },
|
|
365
368
|
{ pattern: /\bdefineActions\s*\(/, folder: 'actions/' }
|
|
366
369
|
];
|
|
367
370
|
function isInItsFolder(path) {
|
|
@@ -474,6 +477,7 @@ function runsAccessFlows() {
|
|
|
474
477
|
return (/from '@trypattern\/sdk\/flows'/.test(text) &&
|
|
475
478
|
/accessFlows\(\s*\{\s*App\s*\}\s*\)/.test(text));
|
|
476
479
|
}
|
|
480
|
+
const WORKER_ENTRY = 'server/worker.ts';
|
|
477
481
|
const CONVENTION_EXPORTS = {
|
|
478
482
|
'schema.ts': ['tables', 'admins'],
|
|
479
483
|
'seed.ts': ['seed'],
|
|
@@ -611,14 +615,18 @@ function exportFindings() {
|
|
|
611
615
|
return [...modules].flatMap(([path, facts]) => {
|
|
612
616
|
const kept = CONVENTION_EXPORTS[relative(ROOT, path)] ?? [];
|
|
613
617
|
const names = used.get(path) ?? new Set();
|
|
618
|
+
const isWorkerEntry = relative(ROOT, path) === WORKER_ENTRY;
|
|
614
619
|
return [
|
|
615
|
-
...facts.passedThrough.map(({ offset }) => ({
|
|
620
|
+
...(isWorkerEntry ? [] : facts.passedThrough).map(({ offset }) => ({
|
|
616
621
|
path,
|
|
617
622
|
offset,
|
|
618
623
|
rule: 'no-re-exports'
|
|
619
624
|
})),
|
|
620
625
|
...facts.exported
|
|
621
|
-
.filter(({ name }) => !
|
|
626
|
+
.filter(({ name }) => !isWorkerEntry &&
|
|
627
|
+
!kept.includes(name) &&
|
|
628
|
+
!names.has(name) &&
|
|
629
|
+
!names.has(EVERYTHING))
|
|
622
630
|
.map(({ offset }) => ({ path, offset, rule: 'no-unused-exports' }))
|
|
623
631
|
];
|
|
624
632
|
});
|
package/dist/rules/lintPlugin.js
CHANGED
|
@@ -55,6 +55,7 @@ const PRIMITIVES = new Set([
|
|
|
55
55
|
'ContextMenu',
|
|
56
56
|
'Dialog',
|
|
57
57
|
'Divider',
|
|
58
|
+
'CopyButton',
|
|
58
59
|
'EmptyState',
|
|
59
60
|
'Form',
|
|
60
61
|
'Icon',
|
|
@@ -65,6 +66,7 @@ const PRIMITIVES = new Set([
|
|
|
65
66
|
'LoadingRows',
|
|
66
67
|
'Menu',
|
|
67
68
|
'PropertyRow',
|
|
69
|
+
'RadioGroup',
|
|
68
70
|
'Row',
|
|
69
71
|
'Screen',
|
|
70
72
|
'ScrollArea',
|
package/dist/rules/messages.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const SRC_STRUCTURE_MESSAGE = "src/ follows the four layers, one folder each, and each folder holds only its kind. Data comes from the server through stores: Pattern gives every table in schema.ts a store, and stores/ holds the others as <Name>.ts with a class extending Store({ state, persist }) (stores/Toasts.ts, stores/IssuesView.ts), listed in `stores` in stores/index.ts. models/ holds each model, the state, answers and commands behind a screen or app chrome, as <Name>.ts with a class extending Model({ state, persist }) (models/IssueList.ts holds class IssueList), listed in `models` in models/index.ts under the route id of its page. actions/ holds the menu entries and shortcuts, one file per thing they act on (actions/issues.ts, actions/appearance.ts), each `export const issueActions = defineActions(({ page, models }) => [...])`, listed in `actions` in actions/index.ts. screens/ holds the pages you see, one file each, named <Name>Screen.tsx (IssueListScreen.tsx), each reading its model with useModel and arranging primitives, with no styles of its own. components/<Name>/ groups the pieces this app adds, and every file in it starts with the folder name (components/IssueList/IssueListRow.tsx); they take props, and only screens and layouts may call useModel. layouts/ holds <Name>Layout.tsx files (layouts/AppLayout.tsx), the frames pages render inside: which pages need sign-in, the sidebar or top bar, and where the Toaster and CommandPalette go. A piece may draw a shape no primitive gives, with tokens for every value, <Text> for its words and primitives for anything clickable. App.ts wires the app together from tables, stores, models, routes and actions. hooks/ holds hooks (use<Name>.ts); lib/ holds non-UI helpers and data (.ts only); theme/ holds theme.ts (the tokens this app re-values) and icons.ts (the icons this app adds). Primitives (Screen, Stack, Text, Button, Menu, Icon) come from @trypattern/sdk/react and are not in this project. Styles live only at the bottom of the component file that owns them; there are no style files. To reuse a look, reuse the component. Move the file where it belongs.";
|
|
1
|
+
export declare const SRC_STRUCTURE_MESSAGE = "src/ follows the four layers, one folder each, and each folder holds only its kind. Data comes from the server through stores: Pattern gives every table in schema.ts a store, and stores/ holds the others as <Name>.ts with a class extending Store({ state, persist }) (stores/Toasts.ts, stores/IssuesView.ts), listed in `stores` in stores/index.ts; a store reaches the app\u2019s own server calls as this.services.<group>.<call>(). services/ holds the app\u2019s own server calls, declared once with their inputs, replies and the memory fake flows run against: services/<group>.ts holds defineServices({ <group>: { ... } }) and its memory handlers, and services/index.ts exports `services` and `memory` for App.ts (server/<group>.ts serves them with serveServices). Stores call them; models never do. models/ holds each model, the state, answers and commands behind a screen or app chrome, as <Name>.ts with a class extending Model({ state, persist }) (models/IssueList.ts holds class IssueList), listed in `models` in models/index.ts under the route id of its page. actions/ holds the menu entries and shortcuts, one file per thing they act on (actions/issues.ts, actions/appearance.ts), each `export const issueActions = defineActions(({ page, models }) => [...])`, listed in `actions` in actions/index.ts. screens/ holds the pages you see, one file each, named <Name>Screen.tsx (IssueListScreen.tsx), each reading its model with useModel and arranging primitives, with no styles of its own. components/<Name>/ groups the pieces this app adds, and every file in it starts with the folder name (components/IssueList/IssueListRow.tsx); they take props, and only screens and layouts may call useModel. layouts/ holds <Name>Layout.tsx files (layouts/AppLayout.tsx), the frames pages render inside: which pages need sign-in, the sidebar or top bar, and where the Toaster and CommandPalette go. A piece may draw a shape no primitive gives, with tokens for every value, <Text> for its words and primitives for anything clickable. App.ts wires the app together from tables, services, stores, models, routes and actions. hooks/ holds hooks (use<Name>.ts); lib/ holds non-UI helpers and data (.ts only); theme/ holds theme.ts (the tokens this app re-values) and icons.ts (the icons this app adds). Primitives (Screen, Stack, Text, Button, Menu, Icon) come from @trypattern/sdk/react and are not in this project. Styles live only at the bottom of the component file that owns them; there are no style files. To reuse a look, reuse the component. Move the file where it belongs.";
|
|
2
2
|
export declare const SCRIPTS_WIRED_MESSAGE = "A project script lives in a labelled folder under scripts/ (a folder named for what it does) and is used by something: package.json, the app launcher, vite.config.ts, flows/, or another script. Delete scripts nothing uses; never leave one-off scripts behind. Checks and tools every Pattern app needs belong in @trypattern/sdk, not here.";
|
|
3
3
|
export declare const NO_COMMENTS_MESSAGE = "Comments are not allowed in Pattern, including directives like @ts-expect-error or oxlint-disable: there are no escape hatches, fix the code. Express intent with names, types and structure; put lasting rationale in the error message of the rule that enforces it, or in the commit message.";
|
|
4
4
|
export declare const NO_POINTER_CURSOR_MESSAGE = "cursor: pointer is not allowed in Pattern. Pattern apps feel native, and native apps keep the default arrow over links and buttons. Remove it; the reset Pattern ships already sets cursor: default.";
|
|
@@ -24,7 +24,7 @@ export declare const CLASSES_NOT_FACTORIES_MESSAGE = "A function that returns an
|
|
|
24
24
|
export declare const ACCESS_FLOWS_MESSAGE = "flows/Access.flow.ts runs Pattern's generated access flows, which prove every table's access rules against the seed on each app test. Keep it: `import { accessFlows } from '@trypattern/sdk/flows'` and `accessFlows({ App })`.";
|
|
25
25
|
export declare const SCHEMA_TABLES_MESSAGE = "schema.ts defines every saved table in one place and says who may do what: `export const tables = defineTables({ issues: { columns: { title: z.string() }, access: { read: 'signedIn', create: 'signedIn', update: 'signedIn', delete: 'signedIn' }, writes: 'optimistic' } })`. It exports a row type for each table, named for one row (`export type Issue = Row<typeof tables.issues>`), and the emails of the people who run the app, `export const admins: string[] = []`, even when there are none yet, so the question is answered on purpose. Add the line shown.";
|
|
26
26
|
export declare const NO_UNUSED_EXPORTS_MESSAGE = "Export only what another file imports. Nothing imports this, so remove `export` (and the code itself if this file does not use it either). Unused exports hide dead code and make a file look like it offers more than it does. Pattern reads `tables` from schema.ts and `seed` from seed.ts, and vite.config.ts and server/worker.ts default-export their config, so those stay.";
|
|
27
|
-
export declare const NO_RE_EXPORTS_MESSAGE = "Do not pass imports through: a file exports only what it defines. Import the thing from where it lives instead (command from @trypattern/sdk, a component from its own file), so every import names the real source.";
|
|
27
|
+
export declare const NO_RE_EXPORTS_MESSAGE = "Do not pass imports through: a file exports only what it defines. Import the thing from where it lives instead (command from @trypattern/sdk, a component from its own file), so every import names the real source. server/worker.ts is the one exception: Cloudflare runs the Workflow and Durable Object classes its Worker exports, so it may export them from where they live.";
|
|
28
28
|
export declare const EXPLICIT_ACCESS_MESSAGE = "Mark every class member public or private (or protected, to override a base class member), so a reader sees at a glance what other code may call: `public setSort = command(...)`, `public override onPageOpen()`, `private loadIssues()`. Use the word private, never #name, so there is one way to say it. Constructors need neither.";
|
|
29
29
|
export declare const IMPORT_FROM_ROOT_MESSAGE = "Import project files by their path from the project root, the way errors and docs name them: `@/src/models/Issues.ts`, `@/schema.ts`. `./File.ts` is fine for a file in the same folder; never `../` or `./folder/File.ts`, so every import says exactly where the file is and moving a file never breaks the files beside it.";
|
|
30
30
|
export declare const MODEL_MEMBERS_MESSAGE = "A model shows the outside world three kinds of public member, so a reader can tell them apart at a glance: getters are its answers (public get hasNext()), command() fields are what it can do (public openNext = command(() => ...)), and onPageOpen() runs when its page opens. Everything else is private, and a private helper that works something out is a method (private neighbours()), never a getter, because Pattern publishes every getter to the screen and the snapshot. A model has no `actions`: menu entries and shortcuts live in src/actions/.";
|
package/dist/rules/messages.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SRC_STRUCTURE_MESSAGE = 'src/ follows the four layers, one folder each, and each folder holds only its kind. Data comes from the server through stores: Pattern gives every table in schema.ts a store, and stores/ holds the others as <Name>.ts with a class extending Store({ state, persist }) (stores/Toasts.ts, stores/IssuesView.ts), listed in `stores` in stores/index.ts. models/ holds each model, the state, answers and commands behind a screen or app chrome, as <Name>.ts with a class extending Model({ state, persist }) (models/IssueList.ts holds class IssueList), listed in `models` in models/index.ts under the route id of its page. actions/ holds the menu entries and shortcuts, one file per thing they act on (actions/issues.ts, actions/appearance.ts), each `export const issueActions = defineActions(({ page, models }) => [...])`, listed in `actions` in actions/index.ts. screens/ holds the pages you see, one file each, named <Name>Screen.tsx (IssueListScreen.tsx), each reading its model with useModel and arranging primitives, with no styles of its own. components/<Name>/ groups the pieces this app adds, and every file in it starts with the folder name (components/IssueList/IssueListRow.tsx); they take props, and only screens and layouts may call useModel. layouts/ holds <Name>Layout.tsx files (layouts/AppLayout.tsx), the frames pages render inside: which pages need sign-in, the sidebar or top bar, and where the Toaster and CommandPalette go. A piece may draw a shape no primitive gives, with tokens for every value, <Text> for its words and primitives for anything clickable. App.ts wires the app together from tables, stores, models, routes and actions. hooks/ holds hooks (use<Name>.ts); lib/ holds non-UI helpers and data (.ts only); theme/ holds theme.ts (the tokens this app re-values) and icons.ts (the icons this app adds). Primitives (Screen, Stack, Text, Button, Menu, Icon) come from @trypattern/sdk/react and are not in this project. Styles live only at the bottom of the component file that owns them; there are no style files. To reuse a look, reuse the component. Move the file where it belongs.';
|
|
1
|
+
export const SRC_STRUCTURE_MESSAGE = 'src/ follows the four layers, one folder each, and each folder holds only its kind. Data comes from the server through stores: Pattern gives every table in schema.ts a store, and stores/ holds the others as <Name>.ts with a class extending Store({ state, persist }) (stores/Toasts.ts, stores/IssuesView.ts), listed in `stores` in stores/index.ts; a store reaches the app’s own server calls as this.services.<group>.<call>(). services/ holds the app’s own server calls, declared once with their inputs, replies and the memory fake flows run against: services/<group>.ts holds defineServices({ <group>: { ... } }) and its memory handlers, and services/index.ts exports `services` and `memory` for App.ts (server/<group>.ts serves them with serveServices). Stores call them; models never do. models/ holds each model, the state, answers and commands behind a screen or app chrome, as <Name>.ts with a class extending Model({ state, persist }) (models/IssueList.ts holds class IssueList), listed in `models` in models/index.ts under the route id of its page. actions/ holds the menu entries and shortcuts, one file per thing they act on (actions/issues.ts, actions/appearance.ts), each `export const issueActions = defineActions(({ page, models }) => [...])`, listed in `actions` in actions/index.ts. screens/ holds the pages you see, one file each, named <Name>Screen.tsx (IssueListScreen.tsx), each reading its model with useModel and arranging primitives, with no styles of its own. components/<Name>/ groups the pieces this app adds, and every file in it starts with the folder name (components/IssueList/IssueListRow.tsx); they take props, and only screens and layouts may call useModel. layouts/ holds <Name>Layout.tsx files (layouts/AppLayout.tsx), the frames pages render inside: which pages need sign-in, the sidebar or top bar, and where the Toaster and CommandPalette go. A piece may draw a shape no primitive gives, with tokens for every value, <Text> for its words and primitives for anything clickable. App.ts wires the app together from tables, services, stores, models, routes and actions. hooks/ holds hooks (use<Name>.ts); lib/ holds non-UI helpers and data (.ts only); theme/ holds theme.ts (the tokens this app re-values) and icons.ts (the icons this app adds). Primitives (Screen, Stack, Text, Button, Menu, Icon) come from @trypattern/sdk/react and are not in this project. Styles live only at the bottom of the component file that owns them; there are no style files. To reuse a look, reuse the component. Move the file where it belongs.';
|
|
2
2
|
export const SCRIPTS_WIRED_MESSAGE = 'A project script lives in a labelled folder under scripts/ (a folder named for what it does) and is used by something: package.json, the app launcher, vite.config.ts, flows/, or another script. Delete scripts nothing uses; never leave one-off scripts behind. Checks and tools every Pattern app needs belong in @trypattern/sdk, not here.';
|
|
3
3
|
export const NO_COMMENTS_MESSAGE = 'Comments are not allowed in Pattern, including directives like @ts-expect-error or oxlint-disable: there are no escape hatches, fix the code. Express intent with names, types and structure; put lasting rationale in the error message of the rule that enforces it, or in the commit message.';
|
|
4
4
|
export const NO_POINTER_CURSOR_MESSAGE = 'cursor: pointer is not allowed in Pattern. Pattern apps feel native, and native apps keep the default arrow over links and buttons. Remove it; the reset Pattern ships already sets cursor: default.';
|
|
@@ -24,7 +24,7 @@ export const CLASSES_NOT_FACTORIES_MESSAGE = 'A function that returns an object
|
|
|
24
24
|
export const ACCESS_FLOWS_MESSAGE = "flows/Access.flow.ts runs Pattern's generated access flows, which prove every table's access rules against the seed on each app test. Keep it: `import { accessFlows } from '@trypattern/sdk/flows'` and `accessFlows({ App })`.";
|
|
25
25
|
export const SCHEMA_TABLES_MESSAGE = "schema.ts defines every saved table in one place and says who may do what: `export const tables = defineTables({ issues: { columns: { title: z.string() }, access: { read: 'signedIn', create: 'signedIn', update: 'signedIn', delete: 'signedIn' }, writes: 'optimistic' } })`. It exports a row type for each table, named for one row (`export type Issue = Row<typeof tables.issues>`), and the emails of the people who run the app, `export const admins: string[] = []`, even when there are none yet, so the question is answered on purpose. Add the line shown.";
|
|
26
26
|
export const NO_UNUSED_EXPORTS_MESSAGE = 'Export only what another file imports. Nothing imports this, so remove `export` (and the code itself if this file does not use it either). Unused exports hide dead code and make a file look like it offers more than it does. Pattern reads `tables` from schema.ts and `seed` from seed.ts, and vite.config.ts and server/worker.ts default-export their config, so those stay.';
|
|
27
|
-
export const NO_RE_EXPORTS_MESSAGE = 'Do not pass imports through: a file exports only what it defines. Import the thing from where it lives instead (command from @trypattern/sdk, a component from its own file), so every import names the real source.';
|
|
27
|
+
export const NO_RE_EXPORTS_MESSAGE = 'Do not pass imports through: a file exports only what it defines. Import the thing from where it lives instead (command from @trypattern/sdk, a component from its own file), so every import names the real source. server/worker.ts is the one exception: Cloudflare runs the Workflow and Durable Object classes its Worker exports, so it may export them from where they live.';
|
|
28
28
|
export const EXPLICIT_ACCESS_MESSAGE = 'Mark every class member public or private (or protected, to override a base class member), so a reader sees at a glance what other code may call: `public setSort = command(...)`, `public override onPageOpen()`, `private loadIssues()`. Use the word private, never #name, so there is one way to say it. Constructors need neither.';
|
|
29
29
|
export const IMPORT_FROM_ROOT_MESSAGE = 'Import project files by their path from the project root, the way errors and docs name them: `@/src/models/Issues.ts`, `@/schema.ts`. `./File.ts` is fine for a file in the same folder; never `../` or `./folder/File.ts`, so every import says exactly where the file is and moving a file never breaks the files beside it.';
|
|
30
30
|
export const MODEL_MEMBERS_MESSAGE = 'A model shows the outside world three kinds of public member, so a reader can tell them apart at a glance: getters are its answers (public get hasNext()), command() fields are what it can do (public openNext = command(() => ...)), and onPageOpen() runs when its page opens. Everything else is private, and a private helper that works something out is a method (private neighbours()), never a getter, because Pattern publishes every getter to the screen and the snapshot. A model has no `actions`: menu entries and shortcuts live in src/actions/.';
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
+
export { call, defineServices } from './services.ts';
|
|
2
|
+
export type { CallInput, CallReply, ServiceCall, ServiceMemory, Services } from './services.ts';
|
|
1
3
|
export { defineTables } from './table.ts';
|
|
2
4
|
export type { Access, Columns, Fields, Level, PublicRow, Row, Seed, Table, TableDefinition, TableOf, Tables, Value, Writes } from './table.ts';
|
package/dist/schema.js
CHANGED
package/dist/server/auth.js
CHANGED
|
@@ -3,6 +3,7 @@ import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { AUTH_URL, authServiceFor } from "./authService.js";
|
|
5
5
|
import { forgetSession, readPass, startSession } from "./session.js";
|
|
6
|
+
import { readTitle } from "./settings.js";
|
|
6
7
|
import { deleteSession } from "./sessions.js";
|
|
7
8
|
import { saveUser } from "./users.js";
|
|
8
9
|
const GOOGLE_COOKIE = 'pattern_google';
|
|
@@ -28,7 +29,10 @@ function randomToken() {
|
|
|
28
29
|
.replaceAll('=', '');
|
|
29
30
|
}
|
|
30
31
|
function safePath(path) {
|
|
31
|
-
return path !== undefined &&
|
|
32
|
+
return path !== undefined &&
|
|
33
|
+
path.startsWith('/') &&
|
|
34
|
+
!path.startsWith('//') &&
|
|
35
|
+
!path.includes('\\')
|
|
32
36
|
? path
|
|
33
37
|
: '/';
|
|
34
38
|
}
|
|
@@ -91,9 +95,11 @@ export const auth = new Hono()
|
|
|
91
95
|
const service = authServiceFor(c.env);
|
|
92
96
|
if (service === undefined)
|
|
93
97
|
return c.json({ error: NOT_SET_UP }, 503);
|
|
98
|
+
const appName = await readTitle(c.env.DB);
|
|
94
99
|
const sent = await service.sendCode({
|
|
95
100
|
email: parsed.data.email.toLowerCase(),
|
|
96
|
-
hostname: hostnameOf(c)
|
|
101
|
+
hostname: hostnameOf(c),
|
|
102
|
+
...(appName === '' ? {} : { appName })
|
|
97
103
|
});
|
|
98
104
|
if (sent === undefined) {
|
|
99
105
|
return c.json({ error: 'Couldn’t send a code to that address.' }, 429);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
1
2
|
import type { Tables } from '../table.ts';
|
|
2
|
-
import type { ServerEnv } from './env.ts';
|
|
3
|
-
export declare function createServer<Group extends Tables>({ tables, admins }: {
|
|
3
|
+
import type { ServerBindings, ServerEnv } from './env.ts';
|
|
4
|
+
export declare function createServer<Group extends Tables, Bindings extends ServerBindings = ServerBindings>({ tables, admins, routes }: {
|
|
4
5
|
tables: Group;
|
|
5
6
|
admins: readonly string[];
|
|
6
|
-
|
|
7
|
+
routes?: Hono<ServerEnv<Bindings>>;
|
|
8
|
+
}): import("hono/hono-base").HonoBase<ServerEnv, (((({
|
|
7
9
|
"/api/health": {
|
|
8
10
|
$get: {
|
|
9
11
|
input: {};
|
|
@@ -105,6 +107,49 @@ export declare function createServer<Group extends Tables>({ tables, admins }: {
|
|
|
105
107
|
};
|
|
106
108
|
};
|
|
107
109
|
}, "/api/auth">) & {
|
|
110
|
+
"/api/app": {
|
|
111
|
+
$get: {
|
|
112
|
+
input: {};
|
|
113
|
+
output: {
|
|
114
|
+
title: string;
|
|
115
|
+
};
|
|
116
|
+
outputFormat: "json";
|
|
117
|
+
status: import("hono/utils/http-status").ContentfulStatusCode;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
}) & {
|
|
121
|
+
"/api/app": {
|
|
122
|
+
$put: {
|
|
123
|
+
input: {};
|
|
124
|
+
output: {
|
|
125
|
+
error: string;
|
|
126
|
+
};
|
|
127
|
+
outputFormat: "json";
|
|
128
|
+
status: 401;
|
|
129
|
+
} | {
|
|
130
|
+
input: {};
|
|
131
|
+
output: {
|
|
132
|
+
error: string;
|
|
133
|
+
};
|
|
134
|
+
outputFormat: "json";
|
|
135
|
+
status: 403;
|
|
136
|
+
} | {
|
|
137
|
+
input: {};
|
|
138
|
+
output: {
|
|
139
|
+
error: string;
|
|
140
|
+
};
|
|
141
|
+
outputFormat: "json";
|
|
142
|
+
status: 400;
|
|
143
|
+
} | {
|
|
144
|
+
input: {};
|
|
145
|
+
output: {
|
|
146
|
+
title: string;
|
|
147
|
+
};
|
|
148
|
+
outputFormat: "json";
|
|
149
|
+
status: import("hono/utils/http-status").ContentfulStatusCode;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
}) & {
|
|
108
153
|
"/api/me": {
|
|
109
154
|
$get: {
|
|
110
155
|
input: {};
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { csrf } from 'hono/csrf';
|
|
3
3
|
import { adminEmails } from "../access.js";
|
|
4
|
+
import { appTitle } from "../appTitle.js";
|
|
4
5
|
import { auth, devSignIn } from "./auth.js";
|
|
5
6
|
import { forgetSession, issuePass, needsRecheck, readPass, rememberUser } from "./session.js";
|
|
6
7
|
import { renewSession } from "./sessions.js";
|
|
8
|
+
import { readTitle, writeTitle } from "./settings.js";
|
|
7
9
|
import { serveTables } from "./tables.js";
|
|
8
10
|
import { findUser } from "./users.js";
|
|
9
11
|
const PUBLIC_PATHS = ['/api/health', '/api/auth/'];
|
|
10
12
|
const VIEWER_OPTIONAL_PATHS = ['/api/tables/'];
|
|
13
|
+
const APP_PATH = '/api/app';
|
|
11
14
|
function knowAdmins(admins) {
|
|
12
15
|
const emails = adminEmails(admins);
|
|
13
16
|
return (c, next) => {
|
|
@@ -18,7 +21,8 @@ function knowAdmins(admins) {
|
|
|
18
21
|
async function attachViewer(c, next) {
|
|
19
22
|
if (PUBLIC_PATHS.some((path) => c.req.path.startsWith(path)))
|
|
20
23
|
return next();
|
|
21
|
-
const isOptional =
|
|
24
|
+
const isOptional = c.req.path === APP_PATH ||
|
|
25
|
+
VIEWER_OPTIONAL_PATHS.some((path) => c.req.path.startsWith(path));
|
|
22
26
|
let pass = await readPass(c);
|
|
23
27
|
if (pass !== undefined && needsRecheck(pass)) {
|
|
24
28
|
const user = await renewSession(c.env.DB, pass.sessionId);
|
|
@@ -38,15 +42,30 @@ async function attachViewer(c, next) {
|
|
|
38
42
|
});
|
|
39
43
|
return next();
|
|
40
44
|
}
|
|
41
|
-
export function createServer({ tables, admins }) {
|
|
45
|
+
export function createServer({ tables, admins, routes }) {
|
|
42
46
|
const authRoutes = import.meta.env.DEV ? auth.route('/', devSignIn) : auth;
|
|
43
|
-
|
|
47
|
+
const server = new Hono()
|
|
44
48
|
.basePath('/api')
|
|
45
49
|
.use(csrf())
|
|
46
50
|
.use(knowAdmins(admins))
|
|
47
51
|
.use(attachViewer)
|
|
48
52
|
.get('/health', (c) => c.json({ ok: true }))
|
|
49
53
|
.route('/auth', authRoutes)
|
|
54
|
+
.get('/app', async (c) => c.json({ title: await readTitle(c.env.DB) }))
|
|
55
|
+
.put('/app', async (c) => {
|
|
56
|
+
const viewer = c.get('viewer');
|
|
57
|
+
if (viewer === null)
|
|
58
|
+
return c.json({ error: 'Signed out' }, 401);
|
|
59
|
+
if (!viewer.isAdmin)
|
|
60
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
61
|
+
const body = await c.req.json().catch(() => undefined);
|
|
62
|
+
const title = appTitle.safeParse(Reflect.get(Object(body), 'title'));
|
|
63
|
+
if (!title.success) {
|
|
64
|
+
return c.json({ error: 'title must be 1 to 60 characters' }, 400);
|
|
65
|
+
}
|
|
66
|
+
await writeTitle(c.env.DB, title.data);
|
|
67
|
+
return c.json({ title: title.data });
|
|
68
|
+
})
|
|
50
69
|
.get('/me', async (c) => {
|
|
51
70
|
const viewer = c.get('viewer');
|
|
52
71
|
const user = viewer === null ? undefined : await findUser(c.env.DB, viewer.userId);
|
|
@@ -57,6 +76,7 @@ export function createServer({ tables, admins }) {
|
|
|
57
76
|
rememberUser(c, user);
|
|
58
77
|
return c.json({ ...user, isAdmin: viewer?.isAdmin ?? false }, 200);
|
|
59
78
|
})
|
|
60
|
-
.route('/tables', serveTables(tables))
|
|
61
|
-
|
|
79
|
+
.route('/tables', serveTables(tables));
|
|
80
|
+
const own = routes;
|
|
81
|
+
return (own === undefined ? server : server.route('/', own)).notFound((c) => c.json({ error: `No route for ${c.req.path}` }, 404));
|
|
62
82
|
}
|
package/dist/server/env.d.ts
CHANGED
|
@@ -6,8 +6,8 @@ export type ServerBindings = {
|
|
|
6
6
|
SESSION_SECRET: string;
|
|
7
7
|
AUTH_SECRET?: string;
|
|
8
8
|
};
|
|
9
|
-
export type ServerEnv = {
|
|
10
|
-
Bindings:
|
|
9
|
+
export type ServerEnv<Bindings extends ServerBindings = ServerBindings> = {
|
|
10
|
+
Bindings: Bindings;
|
|
11
11
|
Variables: {
|
|
12
12
|
viewer: Viewer | null;
|
|
13
13
|
admins: ReadonlySet<string>;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
export { createServer } from './createServer.ts';
|
|
2
2
|
export type { ServerBindings, ServerEnv, Viewer } from './env.ts';
|
|
3
3
|
export { seedSql, serveTables, sqlTable, sqlTables } from './tables.ts';
|
|
4
|
+
export { refused } from '../services.ts';
|
|
5
|
+
export { serveServices } from './services.ts';
|
|
6
|
+
export type { ServiceHandlers } from './services.ts';
|
|
7
|
+
export { sessionCookieFor } from './session.ts';
|
|
4
8
|
export { sessions } from './sessions.ts';
|
|
9
|
+
export { readTitle, seedTitleSql, settings, writeTitle } from './settings.ts';
|
|
5
10
|
export { exchangeReply, exchangeRequest, sendCodeReply, sendCodeRequest, verifiedIdentity, verifyCodeReply, verifyCodeRequest } from './signInWire.ts';
|
|
6
11
|
export type { VerifiedIdentity } from './signInWire.ts';
|
|
7
|
-
export { users } from './users.ts';
|
|
12
|
+
export { findUser, users } from './users.ts';
|
|
8
13
|
export type { D1Database } from '@cloudflare/workers-types';
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { createServer } from "./createServer.js";
|
|
2
2
|
export { seedSql, serveTables, sqlTable, sqlTables } from "./tables.js";
|
|
3
|
+
export { refused } from "../services.js";
|
|
4
|
+
export { serveServices } from "./services.js";
|
|
5
|
+
export { sessionCookieFor } from "./session.js";
|
|
3
6
|
export { sessions } from "./sessions.js";
|
|
7
|
+
export { readTitle, seedTitleSql, settings, writeTitle } from "./settings.js";
|
|
4
8
|
export { exchangeReply, exchangeRequest, sendCodeReply, sendCodeRequest, verifiedIdentity, verifyCodeReply, verifyCodeRequest } from "./signInWire.js";
|
|
5
|
-
export { users } from "./users.js";
|
|
9
|
+
export { findUser, users } from "./users.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Context, Env } from 'hono';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import type { CallInput, CallReply, Services } from '../services.ts';
|
|
4
|
+
export type ServiceHandlers<Group extends Services, E extends Env> = {
|
|
5
|
+
readonly [Name in keyof Group]: {
|
|
6
|
+
readonly [Call in keyof Group[Name]]: (c: Context<E>, input: CallInput<Group[Name][Call]>) => CallReply<Group[Name][Call]> | Promise<CallReply<Group[Name][Call]>>;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export declare function serveServices<Group extends Services, E extends Env>(services: Group, handlers: ServiceHandlers<Group, E>): Hono<E>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ServiceRefused, paramNamesOf } from "../services.js";
|
|
4
|
+
async function inputOf(c, call) {
|
|
5
|
+
const raw = call.method === 'get' ? c.req.query() : await c.req.json().catch(() => ({}));
|
|
6
|
+
return call.input.safeParse(raw);
|
|
7
|
+
}
|
|
8
|
+
const BUILT_IN_PATHS = ['/auth', '/health', '/tables', '/app', '/me'];
|
|
9
|
+
function builtInPathOf(path) {
|
|
10
|
+
return BUILT_IN_PATHS.find((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
|
11
|
+
}
|
|
12
|
+
export function serveServices(services, handlers) {
|
|
13
|
+
for (const [group, calls] of Object.entries(services)) {
|
|
14
|
+
for (const [name, call] of Object.entries(calls)) {
|
|
15
|
+
const taken = builtInPathOf(call.path);
|
|
16
|
+
if (taken !== undefined) {
|
|
17
|
+
throw new Error(`${group}.${name} is served at ${call.path}, under Pattern's own ${taken}, where some calls are open to anyone signed out. Give it a path of its own.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const server = new Hono();
|
|
22
|
+
for (const [group, calls] of Object.entries(services)) {
|
|
23
|
+
for (const [name, call] of Object.entries(calls)) {
|
|
24
|
+
const handler = Reflect.get(Reflect.get(handlers, group), name);
|
|
25
|
+
server.on(call.method.toUpperCase(), call.path, async (c) => {
|
|
26
|
+
const parsed = await inputOf(c, call);
|
|
27
|
+
if (!parsed.success) {
|
|
28
|
+
return c.json({ error: z.prettifyError(parsed.error) }, 400);
|
|
29
|
+
}
|
|
30
|
+
const params = Object.fromEntries(paramNamesOf(call.path).map((key) => [key, c.req.param(key) ?? '']));
|
|
31
|
+
try {
|
|
32
|
+
const reply = await handler(c, {
|
|
33
|
+
...params,
|
|
34
|
+
...parsed.data
|
|
35
|
+
});
|
|
36
|
+
return c.json(call.reply.parse(reply));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!(error instanceof ServiceRefused))
|
|
40
|
+
throw error;
|
|
41
|
+
return c.json({ error: error.message }, error.status);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return server;
|
|
47
|
+
}
|
package/dist/server/session.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export declare function issuePass(c: Context<ServerEnv>, user: StoredUser, sessi
|
|
|
17
17
|
recheckAt: number;
|
|
18
18
|
}>;
|
|
19
19
|
export declare function startSession(c: Context<ServerEnv>, user: StoredUser): Promise<void>;
|
|
20
|
+
export declare function sessionCookieFor(env: ServerEnv['Bindings'], user: StoredUser): Promise<string>;
|
|
20
21
|
export declare function readPass(c: Context<ServerEnv>): Promise<{
|
|
21
22
|
sessionId: string;
|
|
22
23
|
userId: string;
|
package/dist/server/session.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deleteCookie, getSignedCookie, setCookie, setSignedCookie } from 'hono/cookie';
|
|
2
|
+
import { serializeSigned } from 'hono/utils/cookie';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { USER_COOKIE } from "../auth/user.js";
|
|
4
5
|
import { createSession } from "./sessions.js";
|
|
@@ -35,6 +36,16 @@ export async function issuePass(c, user, sessionId) {
|
|
|
35
36
|
export async function startSession(c, user) {
|
|
36
37
|
await issuePass(c, user, await createSession(c.env.DB, user.id));
|
|
37
38
|
}
|
|
39
|
+
export async function sessionCookieFor(env, user) {
|
|
40
|
+
const pass = {
|
|
41
|
+
sessionId: await createSession(env.DB, user.id),
|
|
42
|
+
userId: user.id,
|
|
43
|
+
email: user.email,
|
|
44
|
+
recheckAt: nowInSeconds() + RECHECK_SECONDS
|
|
45
|
+
};
|
|
46
|
+
const cookie = await serializeSigned(SESSION_COOKIE, JSON.stringify(pass), env.SESSION_SECRET);
|
|
47
|
+
return cookie.split(';')[0] ?? cookie;
|
|
48
|
+
}
|
|
38
49
|
export async function readPass(c) {
|
|
39
50
|
const value = await getSignedCookie(c, c.env.SESSION_SECRET, SESSION_COOKIE);
|
|
40
51
|
if (typeof value !== 'string')
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
|
+
export declare const settings: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
3
|
+
name: "settings";
|
|
4
|
+
schema: undefined;
|
|
5
|
+
columns: {
|
|
6
|
+
key: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
7
|
+
name: "key";
|
|
8
|
+
tableName: "settings";
|
|
9
|
+
dataType: "string";
|
|
10
|
+
columnType: "SQLiteText";
|
|
11
|
+
data: string;
|
|
12
|
+
driverParam: string;
|
|
13
|
+
notNull: true;
|
|
14
|
+
hasDefault: false;
|
|
15
|
+
isPrimaryKey: true;
|
|
16
|
+
isAutoincrement: false;
|
|
17
|
+
hasRuntimeDefault: false;
|
|
18
|
+
enumValues: [string, ...string[]];
|
|
19
|
+
baseColumn: never;
|
|
20
|
+
identity: undefined;
|
|
21
|
+
generated: undefined;
|
|
22
|
+
}, {}, {
|
|
23
|
+
length: number | undefined;
|
|
24
|
+
}>;
|
|
25
|
+
value: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
26
|
+
name: "value";
|
|
27
|
+
tableName: "settings";
|
|
28
|
+
dataType: "string";
|
|
29
|
+
columnType: "SQLiteText";
|
|
30
|
+
data: string;
|
|
31
|
+
driverParam: string;
|
|
32
|
+
notNull: true;
|
|
33
|
+
hasDefault: false;
|
|
34
|
+
isPrimaryKey: false;
|
|
35
|
+
isAutoincrement: false;
|
|
36
|
+
hasRuntimeDefault: false;
|
|
37
|
+
enumValues: [string, ...string[]];
|
|
38
|
+
baseColumn: never;
|
|
39
|
+
identity: undefined;
|
|
40
|
+
generated: undefined;
|
|
41
|
+
}, {}, {
|
|
42
|
+
length: number | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
dialect: "sqlite";
|
|
46
|
+
}>;
|
|
47
|
+
export declare function readTitle(d1: D1Database): Promise<string>;
|
|
48
|
+
export declare function writeTitle(d1: D1Database, title: string): Promise<void>;
|
|
49
|
+
export declare function seedTitleSql(title: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
4
|
+
export const settings = sqliteTable('settings', {
|
|
5
|
+
key: text('key').primaryKey(),
|
|
6
|
+
value: text('value').notNull()
|
|
7
|
+
});
|
|
8
|
+
const TITLE = 'title';
|
|
9
|
+
export async function readTitle(d1) {
|
|
10
|
+
const row = await drizzle(d1)
|
|
11
|
+
.select({ value: settings.value })
|
|
12
|
+
.from(settings)
|
|
13
|
+
.where(eq(settings.key, TITLE))
|
|
14
|
+
.get();
|
|
15
|
+
return row?.value ?? '';
|
|
16
|
+
}
|
|
17
|
+
export async function writeTitle(d1, title) {
|
|
18
|
+
await drizzle(d1)
|
|
19
|
+
.insert(settings)
|
|
20
|
+
.values({ key: TITLE, value: title })
|
|
21
|
+
.onConflictDoUpdate({ target: settings.key, set: { value: title } });
|
|
22
|
+
}
|
|
23
|
+
export function seedTitleSql(title) {
|
|
24
|
+
return `INSERT OR IGNORE INTO settings (key, value) VALUES ('${TITLE}', '${title.replaceAll("'", "''")}');`;
|
|
25
|
+
}
|
|
@@ -27,6 +27,7 @@ export declare const exchangeReply: z.ZodObject<{
|
|
|
27
27
|
export declare const sendCodeRequest: z.ZodObject<{
|
|
28
28
|
email: z.ZodEmail;
|
|
29
29
|
hostname: z.ZodString;
|
|
30
|
+
appName: z.ZodOptional<z.ZodString>;
|
|
30
31
|
}, z.core.$strip>;
|
|
31
32
|
export declare const sendCodeReply: z.ZodObject<{
|
|
32
33
|
challenge: z.ZodString;
|
|
@@ -12,7 +12,8 @@ export const exchangeReply = z.object({
|
|
|
12
12
|
});
|
|
13
13
|
export const sendCodeRequest = z.object({
|
|
14
14
|
email: z.email(),
|
|
15
|
-
hostname: z.string()
|
|
15
|
+
hostname: z.string(),
|
|
16
|
+
appName: z.string().min(1).max(60).optional()
|
|
16
17
|
});
|
|
17
18
|
export const sendCodeReply = z.object({ challenge: z.string() });
|
|
18
19
|
export const verifyCodeRequest = z.object({
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Gate } from './gate.ts';
|
|
2
|
+
import type { MemoryApiOptions } from './memoryApi.ts';
|
|
3
|
+
import type { ServiceClient, ServiceMemory, Services } from './services.ts';
|
|
4
|
+
export declare function httpServices<Group extends Services>(services: Group, gate: Gate, onSignedOut: () => void): ServiceClient<Group>;
|
|
5
|
+
export declare function memoryServices<Group extends Services, World>(services: Group, memory: ServiceMemory<Group, World>, world: World, options: MemoryApiOptions): ServiceClient<Group>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { MemoryServer } from "./memoryApi.js";
|
|
2
|
+
import { ServiceRefused, paramNamesOf } from "./services.js";
|
|
3
|
+
function buildClient(services, make) {
|
|
4
|
+
return Object.fromEntries(Object.entries(services).map(([group, calls]) => [
|
|
5
|
+
group,
|
|
6
|
+
Object.fromEntries(Object.entries(calls).map(([name, call]) => [
|
|
7
|
+
name,
|
|
8
|
+
make(group, name, call)
|
|
9
|
+
]))
|
|
10
|
+
]));
|
|
11
|
+
}
|
|
12
|
+
function splitInput(call, input) {
|
|
13
|
+
const params = paramNamesOf(call.path);
|
|
14
|
+
const path = call.path.replaceAll(/:(\w+)/g, (_match, key) => encodeURIComponent(String(input[key] ?? '')));
|
|
15
|
+
const rest = Object.fromEntries(Object.entries(input).filter(([key]) => !params.includes(key)));
|
|
16
|
+
return { path, rest };
|
|
17
|
+
}
|
|
18
|
+
function errorOf(body, status) {
|
|
19
|
+
const error = typeof body === 'object' && body !== null
|
|
20
|
+
? Reflect.get(body, 'error')
|
|
21
|
+
: undefined;
|
|
22
|
+
return typeof error === 'string' ? error : `HTTP ${status}`;
|
|
23
|
+
}
|
|
24
|
+
export function httpServices(services, gate, onSignedOut) {
|
|
25
|
+
return buildClient(services, (group, name, call) => (input = {}) => gate.pass(`${group}.${name}`, async () => {
|
|
26
|
+
const { path, rest } = splitInput(call, input);
|
|
27
|
+
const sent = call.input.parse(rest);
|
|
28
|
+
const query = call.method === 'get' && Object.keys(sent).length > 0
|
|
29
|
+
? `?${new URLSearchParams(Object.entries(sent).map(([key, value]) => [
|
|
30
|
+
key,
|
|
31
|
+
String(value)
|
|
32
|
+
])).toString()}`
|
|
33
|
+
: '';
|
|
34
|
+
const response = await fetch(`/api${path}${query}`, {
|
|
35
|
+
method: call.method.toUpperCase(),
|
|
36
|
+
...(call.method === 'post'
|
|
37
|
+
? {
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
body: JSON.stringify(sent)
|
|
40
|
+
}
|
|
41
|
+
: {})
|
|
42
|
+
});
|
|
43
|
+
const body = await response.json().catch(() => null);
|
|
44
|
+
if (response.status === 401)
|
|
45
|
+
onSignedOut();
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new ServiceRefused(response.status, errorOf(body, response.status));
|
|
48
|
+
}
|
|
49
|
+
return call.reply.parse(body);
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
export function memoryServices(services, memory, world, options) {
|
|
53
|
+
const servers = new Map();
|
|
54
|
+
return buildClient(services, (group, name, call) => (input = {}) => {
|
|
55
|
+
const server = servers.get(group) ?? new MemoryServer(group, options);
|
|
56
|
+
servers.set(group, server);
|
|
57
|
+
return server.reply(name, () => {
|
|
58
|
+
const handlers = Reflect.get(memory, group);
|
|
59
|
+
const handler = typeof handlers === 'object' && handlers !== null
|
|
60
|
+
? Reflect.get(handlers, name)
|
|
61
|
+
: undefined;
|
|
62
|
+
if (typeof handler !== 'function') {
|
|
63
|
+
throw new Error(`The memory fake has no ${group}.${name}`);
|
|
64
|
+
}
|
|
65
|
+
const { rest } = splitInput(call, input);
|
|
66
|
+
const params = Object.fromEntries(paramNamesOf(call.path).map((key) => [key, String(input[key] ?? '')]));
|
|
67
|
+
return call.reply.parse(handler(world, { ...params, ...call.input.parse(rest) }));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|