@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.
@@ -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,3 @@
1
+ export const PATTERN_KEYS = {
2
+ 'pattern-2026-10': 'R0VOZWJtSyBR2jwbN1U3nci1WImH9ZTy27KIw4igaXs'
3
+ };
@@ -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
+ }
@@ -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
  }
@@ -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
  }
@@ -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: import("hono/utils/http-status").ContentfulStatusCode;
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: 404;
58
+ status: 400;
46
59
  };
47
60
  };
48
61
  } & {
@@ -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
- const rows = await (typeof scope === 'object'
168
- ? query.where(eq(column(target, 'ownerId'), scope.ownerId))
169
- : query).orderBy(asc(column(target, 'createdAt')), asc(column(target, 'id')));
170
- return c.json(rows);
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'));
@@ -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 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()}`
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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trypattern/sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",