@trypattern/sdk 0.1.7 → 0.1.9
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/auth/user.d.ts +2 -1
- package/dist/auth/user.js +2 -1
- package/dist/cli/app.js +2 -2
- package/dist/cli/publishing.js +3 -3
- package/dist/components/AppFrame.d.ts +2 -1
- package/dist/components/AppFrame.js +2 -2
- package/dist/components/Appear.d.ts +4 -1
- package/dist/components/Appear.js +82 -3
- package/dist/components/Link.js +8 -3
- package/dist/components/ListRow.d.ts +2 -1
- package/dist/components/ListRow.js +7 -4
- package/dist/components/Sidebar.js +2 -1
- package/dist/patternAccess.d.ts +21 -0
- package/dist/patternAccess.js +110 -0
- package/dist/publishWire.d.ts +316 -0
- package/dist/publishWire.js +107 -0
- package/dist/server/auth.js +3 -3
- package/dist/server/createServer.d.ts +87 -3
- package/dist/server/createServer.js +6 -0
- package/dist/server/database.d.ts +74 -0
- package/dist/server/database.js +93 -0
- package/dist/server/keyset.d.ts +13 -0
- package/dist/server/keyset.js +52 -0
- package/dist/server/patternKeys.d.ts +1 -0
- package/dist/server/patternKeys.js +3 -0
- package/dist/server/patternViewer.d.ts +6 -0
- package/dist/server/patternViewer.js +27 -0
- package/dist/server/services.js +1 -1
- package/dist/server/session.js +3 -4
- package/dist/server/tables.d.ts +16 -3
- package/dist/server/tables.js +15 -6
- package/dist/serviceClients.js +3 -5
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { asc, count } from 'drizzle-orm';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { PAGE_PROBLEM, afterCursor, pageOf, pageReply } from "./keyset.js";
|
|
6
|
+
import { sqlTable } from "./tables.js";
|
|
7
|
+
import { users } from "./users.js";
|
|
8
|
+
const userRow = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
email: z.string(),
|
|
11
|
+
name: z.string(),
|
|
12
|
+
avatarUrl: z.string().nullable(),
|
|
13
|
+
createdAt: z.string()
|
|
14
|
+
});
|
|
15
|
+
const USERS_ACCESS = {
|
|
16
|
+
read: 'admin',
|
|
17
|
+
create: 'server',
|
|
18
|
+
update: 'server',
|
|
19
|
+
delete: 'server'
|
|
20
|
+
};
|
|
21
|
+
function adminRefusal(c) {
|
|
22
|
+
const viewer = c.get('viewer');
|
|
23
|
+
if (viewer === null)
|
|
24
|
+
return c.json({ error: 'Signed out' }, 401);
|
|
25
|
+
return viewer.isAdmin ? undefined : c.json({ error: 'forbidden' }, 403);
|
|
26
|
+
}
|
|
27
|
+
function jsonSchemaOf(schema) {
|
|
28
|
+
return z.toJSONSchema(schema, { unrepresentable: 'any' });
|
|
29
|
+
}
|
|
30
|
+
async function rowCount(c, table) {
|
|
31
|
+
const counted = await drizzle(c.env.DB)
|
|
32
|
+
.select({ count: count() })
|
|
33
|
+
.from(sqlTable(table))
|
|
34
|
+
.get();
|
|
35
|
+
return counted?.count ?? 0;
|
|
36
|
+
}
|
|
37
|
+
async function userCount(c) {
|
|
38
|
+
const counted = await drizzle(c.env.DB)
|
|
39
|
+
.select({ count: count() })
|
|
40
|
+
.from(users)
|
|
41
|
+
.get();
|
|
42
|
+
return counted?.count ?? 0;
|
|
43
|
+
}
|
|
44
|
+
export function serveDatabase(tables) {
|
|
45
|
+
const listed = Object.values(tables).filter((table) => table.access.read !== 'server');
|
|
46
|
+
return new Hono()
|
|
47
|
+
.get('/schema', async (c) => {
|
|
48
|
+
const refusal = adminRefusal(c);
|
|
49
|
+
if (refusal !== undefined)
|
|
50
|
+
return refusal;
|
|
51
|
+
const own = await Promise.all(listed.map(async (table) => ({
|
|
52
|
+
name: table.name,
|
|
53
|
+
schema: jsonSchemaOf(table.row),
|
|
54
|
+
access: table.access,
|
|
55
|
+
public: [...table.public],
|
|
56
|
+
rowCount: await rowCount(c, table),
|
|
57
|
+
isBuiltIn: false
|
|
58
|
+
})));
|
|
59
|
+
const builtIn = {
|
|
60
|
+
name: 'users',
|
|
61
|
+
schema: jsonSchemaOf(userRow),
|
|
62
|
+
access: USERS_ACCESS,
|
|
63
|
+
public: [],
|
|
64
|
+
rowCount: await userCount(c),
|
|
65
|
+
isBuiltIn: true
|
|
66
|
+
};
|
|
67
|
+
return c.json({ tables: [...own, builtIn] });
|
|
68
|
+
})
|
|
69
|
+
.get('/tables/users', async (c) => {
|
|
70
|
+
const refusal = adminRefusal(c);
|
|
71
|
+
if (refusal !== undefined)
|
|
72
|
+
return refusal;
|
|
73
|
+
const page = pageOf(c);
|
|
74
|
+
if (page === 'invalid') {
|
|
75
|
+
return c.json({ error: PAGE_PROBLEM }, 400);
|
|
76
|
+
}
|
|
77
|
+
const query = drizzle(c.env.DB)
|
|
78
|
+
.select({
|
|
79
|
+
id: users.id,
|
|
80
|
+
email: users.email,
|
|
81
|
+
name: users.name,
|
|
82
|
+
avatarUrl: users.avatarUrl,
|
|
83
|
+
createdAt: users.createdAt
|
|
84
|
+
})
|
|
85
|
+
.from(users)
|
|
86
|
+
.where(afterCursor(users.createdAt, users.id, page))
|
|
87
|
+
.orderBy(asc(users.createdAt), asc(users.id));
|
|
88
|
+
const rows = await (page === undefined
|
|
89
|
+
? query
|
|
90
|
+
: query.limit(page.limit + 1));
|
|
91
|
+
return pageReply(c, rows, page);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
import type { ServerEnv } from './env.ts';
|
|
4
|
+
export declare const PAGE_PROBLEM = "limit must be 1 to 1000, after a cursor";
|
|
5
|
+
export type Page = {
|
|
6
|
+
readonly limit: number;
|
|
7
|
+
readonly after: readonly [string, string] | undefined;
|
|
8
|
+
};
|
|
9
|
+
export declare function pageOf(c: Context<ServerEnv>): Page | undefined | 'invalid';
|
|
10
|
+
export declare function afterCursor(createdAt: SQLiteColumn, id: SQLiteColumn, page: Page | undefined): import("drizzle-orm").SQL<unknown> | undefined;
|
|
11
|
+
export declare function pageReply(c: Context<ServerEnv>, rows: readonly Readonly<Record<string, unknown>>[], page: Page | undefined): Response & import("hono").TypedResponse<readonly {
|
|
12
|
+
readonly [x: string]: import("hono/utils/types").JSONValue;
|
|
13
|
+
}[], import("hono/utils/http-status").ContentfulStatusCode, "json">;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { and, eq, gt, or } from 'drizzle-orm';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { NEXT_PAGE_HEADER } from "../patternAccess.js";
|
|
4
|
+
const PAGE_LIMIT = 1000;
|
|
5
|
+
export const PAGE_PROBLEM = 'limit must be 1 to 1000, after a cursor';
|
|
6
|
+
const pageQuery = z.object({
|
|
7
|
+
limit: z.coerce.number().int().min(1).max(PAGE_LIMIT).optional(),
|
|
8
|
+
after: z.string().min(1).optional()
|
|
9
|
+
});
|
|
10
|
+
const cursor = z.tuple([z.string(), z.string()]);
|
|
11
|
+
function cursorOf(row) {
|
|
12
|
+
return btoa(JSON.stringify([row['createdAt'], row['id']]))
|
|
13
|
+
.replaceAll('+', '-')
|
|
14
|
+
.replaceAll('/', '_')
|
|
15
|
+
.replaceAll('=', '');
|
|
16
|
+
}
|
|
17
|
+
function cursorParts(text) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = cursor.safeParse(JSON.parse(atob(text.replaceAll('-', '+').replaceAll('_', '/'))));
|
|
20
|
+
return parsed.success ? parsed.data : undefined;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function pageOf(c) {
|
|
27
|
+
const parsed = pageQuery.safeParse(c.req.query());
|
|
28
|
+
if (!parsed.success)
|
|
29
|
+
return 'invalid';
|
|
30
|
+
const { limit, after } = parsed.data;
|
|
31
|
+
if (limit === undefined)
|
|
32
|
+
return after === undefined ? undefined : 'invalid';
|
|
33
|
+
if (after === undefined)
|
|
34
|
+
return { limit, after: undefined };
|
|
35
|
+
const parts = cursorParts(after);
|
|
36
|
+
return parts === undefined ? 'invalid' : { limit, after: parts };
|
|
37
|
+
}
|
|
38
|
+
export function afterCursor(createdAt, id, page) {
|
|
39
|
+
if (page?.after === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const [at, lastId] = page.after;
|
|
42
|
+
return or(gt(createdAt, at), and(eq(createdAt, at), gt(id, lastId)));
|
|
43
|
+
}
|
|
44
|
+
export function pageReply(c, rows, page) {
|
|
45
|
+
if (page === undefined || rows.length <= page.limit)
|
|
46
|
+
return c.json(rows);
|
|
47
|
+
const shown = rows.slice(0, page.limit);
|
|
48
|
+
const last = shown.at(-1);
|
|
49
|
+
if (last !== undefined)
|
|
50
|
+
c.header(NEXT_PAGE_HEADER, cursorOf(last));
|
|
51
|
+
return c.json(shown);
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PATTERN_KEYS: Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono';
|
|
2
|
+
import type { ServerEnv } from './env.ts';
|
|
3
|
+
export declare function bearerOf(c: Context<ServerEnv>): string | undefined;
|
|
4
|
+
export declare function attachPatternViewer(c: Context<ServerEnv>, next: Next, token: string): Promise<void | (Response & import("hono").TypedResponse<{
|
|
5
|
+
error: string;
|
|
6
|
+
}, 401, "json">)>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { verifiedTablesAssertion } from "../patternAccess.js";
|
|
2
|
+
import { PATTERN_KEYS } from "./patternKeys.js";
|
|
3
|
+
const BEARER = /^bearer\s+/i;
|
|
4
|
+
const SCHEMA_PATH = '/api/schema';
|
|
5
|
+
const TABLES_PATH = '/api/tables/';
|
|
6
|
+
export function bearerOf(c) {
|
|
7
|
+
const header = c.req.header('authorization') ?? '';
|
|
8
|
+
return BEARER.test(header) ? header.replace(BEARER, '').trim() : undefined;
|
|
9
|
+
}
|
|
10
|
+
function isReadByPattern(c) {
|
|
11
|
+
const { path } = c.req;
|
|
12
|
+
return (c.req.method === 'GET' &&
|
|
13
|
+
(path === SCHEMA_PATH || path.startsWith(TABLES_PATH)));
|
|
14
|
+
}
|
|
15
|
+
export async function attachPatternViewer(c, next, token) {
|
|
16
|
+
const email = isReadByPattern(c)
|
|
17
|
+
? await verifiedTablesAssertion(token, {
|
|
18
|
+
hostname: new URL(c.req.url).hostname,
|
|
19
|
+
keys: PATTERN_KEYS,
|
|
20
|
+
now: Math.floor(Date.now() / 1000)
|
|
21
|
+
})
|
|
22
|
+
: undefined;
|
|
23
|
+
if (email === undefined)
|
|
24
|
+
return c.json({ error: 'Signed out' }, 401);
|
|
25
|
+
c.set('viewer', { userId: `pattern:${email}`, isAdmin: true });
|
|
26
|
+
return next();
|
|
27
|
+
}
|
package/dist/server/services.js
CHANGED
|
@@ -5,7 +5,7 @@ async function inputOf(c, call) {
|
|
|
5
5
|
const raw = call.method === 'get' ? c.req.query() : await c.req.json().catch(() => ({}));
|
|
6
6
|
return call.input.safeParse(raw);
|
|
7
7
|
}
|
|
8
|
-
const BUILT_IN_PATHS = ['/auth', '/health', '/tables', '/app', '/me'];
|
|
8
|
+
const BUILT_IN_PATHS = ['/auth', '/health', '/tables', '/app', '/me', '/schema'];
|
|
9
9
|
function builtInPathOf(path) {
|
|
10
10
|
return BUILT_IN_PATHS.find((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
|
11
11
|
}
|
package/dist/server/session.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { deleteCookie, getSignedCookie, setCookie, setSignedCookie } from 'hono/cookie';
|
|
2
2
|
import { serializeSigned } from 'hono/utils/cookie';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { USER_COOKIE } from "../auth/user.js";
|
|
4
|
+
import { SESSION_COOKIE, USER_COOKIE } from "../auth/user.js";
|
|
5
5
|
import { createSession } from "./sessions.js";
|
|
6
|
-
const SESSION_COOKIE = 'pattern_session';
|
|
7
6
|
const RECHECK_SECONDS = 15 * 60;
|
|
8
7
|
const SESSION_SECONDS = 30 * 86_400;
|
|
9
8
|
const cookieOptions = {
|
|
@@ -61,6 +60,6 @@ export async function readPass(c) {
|
|
|
61
60
|
}
|
|
62
61
|
export const needsRecheck = (pass) => pass.recheckAt <= nowInSeconds();
|
|
63
62
|
export function forgetSession(c) {
|
|
64
|
-
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
|
65
|
-
deleteCookie(c, USER_COOKIE, { path: '/' });
|
|
63
|
+
deleteCookie(c, SESSION_COOKIE, { path: '/', secure: true });
|
|
64
|
+
deleteCookie(c, USER_COOKIE, { path: '/', secure: true });
|
|
66
65
|
}
|
package/dist/server/tables.d.ts
CHANGED
|
@@ -13,6 +13,17 @@ export declare function seedSql<Group extends Tables>(tables: Group, seed: Seed<
|
|
|
13
13
|
export declare function serveTables<Group extends Tables>(tables: Group): import("hono/hono-base").HonoBase<ServerEnv, {
|
|
14
14
|
"/:table": {
|
|
15
15
|
$get: {
|
|
16
|
+
input: {
|
|
17
|
+
param: {
|
|
18
|
+
table: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
output: readonly {
|
|
22
|
+
readonly [x: string]: import("hono/utils/types").JSONValue;
|
|
23
|
+
}[];
|
|
24
|
+
outputFormat: "json";
|
|
25
|
+
status: import("hono/utils/http-status").ContentfulStatusCode;
|
|
26
|
+
} | {
|
|
16
27
|
input: {
|
|
17
28
|
param: {
|
|
18
29
|
table: string;
|
|
@@ -29,9 +40,11 @@ export declare function serveTables<Group extends Tables>(tables: Group): import
|
|
|
29
40
|
table: string;
|
|
30
41
|
};
|
|
31
42
|
};
|
|
32
|
-
output: {
|
|
43
|
+
output: {
|
|
44
|
+
error: string;
|
|
45
|
+
};
|
|
33
46
|
outputFormat: "json";
|
|
34
|
-
status:
|
|
47
|
+
status: 404;
|
|
35
48
|
} | {
|
|
36
49
|
input: {
|
|
37
50
|
param: {
|
|
@@ -42,7 +55,7 @@ export declare function serveTables<Group extends Tables>(tables: Group): import
|
|
|
42
55
|
error: string;
|
|
43
56
|
};
|
|
44
57
|
outputFormat: "json";
|
|
45
|
-
status:
|
|
58
|
+
status: 400;
|
|
46
59
|
};
|
|
47
60
|
};
|
|
48
61
|
} & {
|
package/dist/server/tables.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { asc, eq, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
2
2
|
import { drizzle } from 'drizzle-orm/d1';
|
|
3
3
|
import { integer, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { changeRefusal, createRefusal, listRefusal, readScope } from "../access.js";
|
|
7
|
+
import { PAGE_PROBLEM, afterCursor, pageOf, pageReply } from "./keyset.js";
|
|
7
8
|
import { ownerIdOf, seededRows, snakeCase } from "../table.js";
|
|
8
9
|
function typesOf(schema) {
|
|
9
10
|
const { type } = z.toJSONSchema(schema);
|
|
@@ -161,13 +162,21 @@ export function serveTables(tables) {
|
|
|
161
162
|
if (refusal !== undefined)
|
|
162
163
|
return refuse(c, refusal);
|
|
163
164
|
const scope = readScope(table, viewer);
|
|
165
|
+
const page = pageOf(c);
|
|
166
|
+
if (page === 'invalid') {
|
|
167
|
+
return c.json({ error: PAGE_PROBLEM }, 400);
|
|
168
|
+
}
|
|
164
169
|
const query = drizzle(c.env.DB)
|
|
165
170
|
.select(selection(table, target))
|
|
166
|
-
.from(target)
|
|
167
|
-
|
|
168
|
-
?
|
|
169
|
-
:
|
|
170
|
-
|
|
171
|
+
.from(target)
|
|
172
|
+
.where(and(typeof scope === 'object'
|
|
173
|
+
? eq(column(target, 'ownerId'), scope.ownerId)
|
|
174
|
+
: undefined, afterCursor(column(target, 'createdAt'), column(target, 'id'), page)))
|
|
175
|
+
.orderBy(asc(column(target, 'createdAt')), asc(column(target, 'id')));
|
|
176
|
+
const rows = await (page === undefined
|
|
177
|
+
? query
|
|
178
|
+
: query.limit(page.limit + 1));
|
|
179
|
+
return pageReply(c, rows, page);
|
|
171
180
|
})
|
|
172
181
|
.get('/:table/public', async (c) => {
|
|
173
182
|
const entry = known.get(c.req.param('table'));
|
package/dist/serviceClients.js
CHANGED
|
@@ -25,11 +25,9 @@ export function httpServices(services, gate, onSignedOut) {
|
|
|
25
25
|
return buildClient(services, (group, name, call) => (input = {}) => gate.pass(`${group}.${name}`, async () => {
|
|
26
26
|
const { path, rest } = splitInput(call, input);
|
|
27
27
|
const sent = call.input.parse(rest);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
String(value)
|
|
32
|
-
])).toString()}`
|
|
28
|
+
const fields = Object.entries(sent).filter(([, value]) => value !== undefined);
|
|
29
|
+
const query = call.method === 'get' && fields.length > 0
|
|
30
|
+
? `?${new URLSearchParams(fields.map(([key, value]) => [key, String(value)])).toString()}`
|
|
33
31
|
: '';
|
|
34
32
|
const response = await fetch(`/api${path}${query}`, {
|
|
35
33
|
method: call.method.toUpperCase(),
|