@trylight/app 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/README.md CHANGED
@@ -7,9 +7,9 @@ unchanged; entities and the schema sync are new here.
7
7
 
8
8
  | Import | What |
9
9
  |---|---|
10
- | `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `fieldSource` (the `{url, evidence}` schema) |
11
- | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed) and `app.contracts` (`useInvocation`) |
12
- | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.invoke.handler`, `light.chat` |
10
+ | `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `lightImages` (the app's copy of its logos and photos); `fieldSource` (the `{url, evidence}` schema) |
11
+ | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
12
+ | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
13
13
  | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge |
14
14
  | `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
15
15
  | `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
@@ -21,8 +21,8 @@ through `entity()`. Each request is parsed with the table's own Zod schema
21
21
  (drizzle-zod; unknown or platform columns are refused, `field_sources` must be
22
22
  `{url, evidence}` per real column), goes through the app's door (members-only:
23
23
  a member; public: anyone reads, people signed in write; `write: "agents"`
24
- tables refuse the app), must come from the app's own pages, and each change is
25
- written with a `light_changes` row naming the person, in one transaction. An
24
+ tables refuse the app), and must come from the app's own pages; each write
25
+ names its person for the table's change trigger (below). An
26
26
  edited value loses its source; sources sent are merged by column. Lists sort
27
27
  by the asked column, then id. With no sign-in configured only a non-production
28
28
  server asked on loopback answers (as "Local developer"); anything else gets 503.
@@ -33,6 +33,17 @@ cached list and row at once and stays pending until the table's refetch lands
33
33
 
34
34
  Joins and aggregates stay typed server functions over `light.db`.
35
35
 
36
+ ## Images
37
+
38
+ `/api/light/image?src=…` serves a logo or photo a row points at from the
39
+ app's own copy in `light_images`: the first view by a member (or any view
40
+ with no sign-in configured) fetches and keeps it, and every later one, and
41
+ the CDN for a year, is the copy. Only public addresses are connected to (checked on the resolved address, each
42
+ redirect hop included); images only (SVG served sandboxed, `nosniff`), size-
43
+ and time-capped. A source that gives no image is a `no-store` 404, so the
44
+ mark shows its letters and the next view asks again. Members-only apps need
45
+ sign-in; a stranger to a public app only reads what members kept.
46
+
36
47
  ## Schema sync
37
48
 
38
49
  `plan()` diffs `db/schema` against the newest migration snapshot with
@@ -40,7 +51,7 @@ drizzle-kit's API, removals first and additions second, so drizzle-kit never
40
51
  has to ask whether a column was renamed (it can't without a terminal).
41
52
  The change is written as a drizzle migration (`db/migrations`,
42
53
  drizzle-kit's own layout) and applied, whatever it does to the rows there (a
43
- removed column's values go with it); one the rows refuse is taken back out,
54
+ removed column's values are archived first, below); one the rows refuse is taken back out,
44
55
  so the change stays pending rather than blocking every later migrate. Changed
45
56
  indexes and constraints are dropped before the columns they cover and enums
46
57
  only after, so the two halves apply in order. Syncs and migrates each hold a Postgres advisory lock (with a
@@ -49,6 +60,22 @@ overlap, and a process that dies releases it. `migrate` applies the journal; in
49
60
  asks the platform's migrate socket to run it. `check` (in `pnpm build`)
50
61
  fails while `db/schema` has changes no migration holds.
51
62
 
63
+ ## Recoverable deletes
64
+
65
+ `src/db/audit.ts`, in Postgres itself. Every entity table has a row trigger
66
+ that records each insert, update, delete and truncate in `light_changes` (the
67
+ new row, `{ column: { from, to } }`, or the old row), whoever wrote it: the
68
+ actor is `light.actor_kind/_id/_name`, which the entities route and Plaza's
69
+ `mii db write` set per transaction, or else the database role. A migration
70
+ that drops a table or column or changes a column's type first copies what it
71
+ destroys into `light_archive` (`light_archive_values`, called at the top of
72
+ the migration, so in its transaction) and the migrate prints "Archived N
73
+ values from table.column". Both tables refuse UPDATE, DELETE and TRUNCATE (a guard against mistakes:
74
+ the owner role can still drop the trigger). DDL an agent runs itself (`drop
75
+ table` through `mii db write`) is not archived.
76
+ `migrate` installs the functions before applying migrations and the triggers
77
+ after; the template's AGENTS.md has the restore SQL.
78
+
52
79
  ## Develop
53
80
 
54
81
  ```bash
@@ -58,4 +85,6 @@ TEST_DATABASE_URL=postgres://you@127.0.0.1:5432/postgres pnpm test # with the
58
85
  pnpm run build # dist/, what the template installs
59
86
  ```
60
87
 
61
- Not published yet: the template depends on this directory (`file:`).
88
+ Published to npm; the template installs it from there, so a change here
89
+ reaches new apps once it is published and the template's lockfile takes it.
90
+ To publish: bump `version`, then `pnpm publish` (`prepack` builds `dist`).
package/dist/client.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { Entity } from "./schema.js";
4
4
  export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
5
5
  export type { FieldSource, FieldSources } from "./schema.js";
6
6
  export { siteOf } from "./schema.js";
7
+ export { imageSrc } from "./images.js";
7
8
  export { InvocationError, type InvocationStatus } from "@shubh90/app-runtime/invoke";
8
9
  export declare function createApp<C extends Contracts = Record<never, never>>(): {
9
10
  table: typeof tableClient;
package/dist/client.js CHANGED
@@ -12,6 +12,7 @@ import { z } from "zod/v4";
12
12
  import { ENTITIES_PATH, EntityError } from "./entities.js";
13
13
  export { EntityError, LIST_LIMIT } from "./entities.js";
14
14
  export { siteOf } from "./schema.js";
15
+ export { imageSrc } from "./images.js";
15
16
  export { InvocationError } from "@shubh90/app-runtime/invoke";
16
17
  export function createApp() {
17
18
  return { table: tableClient, contracts: createInvoker() };
@@ -0,0 +1,36 @@
1
+ import type postgres from "postgres";
2
+ /** Makes light_archive and the functions migrations and triggers call. Runs before migrating. */
3
+ export declare function prepareAudit(sql: postgres.Sql): Promise<void>;
4
+ /**
5
+ * The entity tables `schema` declares. Read before migrating: the triggers
6
+ * write into light_changes, so a schema that doesn't export it is refused
7
+ * before any migration applies.
8
+ */
9
+ export declare function entityTables(schema: Record<string, unknown>): string[];
10
+ /**
11
+ * Puts the change trigger on each of `tables` the database has (one a
12
+ * migration hasn't made yet is tracked on the migrate that makes it), and
13
+ * makes light_changes append-only. Runs after migrating.
14
+ */
15
+ export declare function trackTables(sql: postgres.Sql, tables: string[]): Promise<void>;
16
+ /** A table, or a column of one, that a migration destroys: what it archives first. */
17
+ export type Destroyed = {
18
+ table: string;
19
+ column: string | null;
20
+ };
21
+ /**
22
+ * What going from `previous` to `current` (drizzle-kit snapshots' `tables`)
23
+ * destroys: dropped tables, dropped columns, and columns whose type changes
24
+ * (Postgres converts the values, which may not survive the trip back).
25
+ */
26
+ export declare function destroyed(previous: Record<string, unknown>, current: Record<string, unknown>): Destroyed[];
27
+ /** The statements that archive what migration `tag` destroys, to run first in it. */
28
+ export declare function archiveStatements(archive: Destroyed[], tag: string): string[];
29
+ /**
30
+ * Collects what a migrate archived, to print once its transaction commits
31
+ * (a migration that fails archives nothing). Other notices are dropped.
32
+ */
33
+ export declare function archiveNotices(): {
34
+ onnotice(notice: postgres.Notice): void;
35
+ print(): void;
36
+ };
@@ -0,0 +1,193 @@
1
+ // Recoverable deletes: change triggers into light_changes, an archive step at
2
+ // the top of destructive migrations, both tables append-only (README,
3
+ // "Recoverable deletes"; the restore is the template AGENTS.md's).
4
+ import { getTableName, is } from "drizzle-orm";
5
+ import { PgTable } from "drizzle-orm/pg-core";
6
+ import { z } from "zod/v4";
7
+ import { isEntity, lightChanges } from "../schema.js";
8
+ /** The SQLSTATE of the notice an archive raises, so the CLI prints just those. */
9
+ const ARCHIVED_NOTICE = "LA001";
10
+ /**
11
+ * Installed before migrations run (a migration's archive step calls these),
12
+ * all idempotent. No input reaches this text; the functions quote the
13
+ * identifiers they are given with format('%I').
14
+ */
15
+ const PREPARE = `
16
+ create table if not exists light_archive (
17
+ id uuid primary key default gen_random_uuid(),
18
+ table_name text not null,
19
+ -- null when the whole row was archived (its table was dropped)
20
+ column_name text,
21
+ -- the row's id; null for a table without a uuid id
22
+ row_id uuid,
23
+ value jsonb not null,
24
+ migration text not null,
25
+ archived_at timestamptz not null default now()
26
+ );
27
+ create index if not exists light_archive_table_idx on light_archive (table_name, column_name);
28
+
29
+ create or replace function light_append_only() returns trigger language plpgsql as $$
30
+ begin
31
+ raise exception '% is append-only: it is the record of what changed and what was removed, so its rows are never changed or deleted.', tg_table_name
32
+ using errcode = 'insufficient_privilege';
33
+ end $$;
34
+ create or replace trigger light_append_only before update or delete or truncate on light_archive
35
+ for each statement execute function light_append_only();
36
+
37
+ create or replace function light_record_change() returns trigger language plpgsql as $$
38
+ declare
39
+ -- A setting set local reads '' (not null) once its transaction has ended.
40
+ actor_kind text := coalesce(nullif(current_setting('light.actor_kind', true), ''), 'database');
41
+ actor_id text := nullif(current_setting('light.actor_id', true), '');
42
+ actor_name text := coalesce(nullif(current_setting('light.actor_name', true), ''), current_user);
43
+ row_id uuid;
44
+ action text;
45
+ changes jsonb;
46
+ begin
47
+ if tg_op = 'TRUNCATE' then
48
+ execute format(
49
+ 'insert into light_changes (table_name, row_id, action, changes, actor_kind, actor_id, actor_name)
50
+ select $1, t.id, ''delete'', to_jsonb(t), $2, $3, $4 from %I.%I t', tg_table_schema, tg_table_name)
51
+ using tg_table_name, actor_kind, actor_id, actor_name;
52
+ return null;
53
+ elsif tg_op = 'INSERT' then
54
+ row_id := new.id;
55
+ action := 'create';
56
+ changes := to_jsonb(new);
57
+ elsif tg_op = 'DELETE' then
58
+ row_id := old.id;
59
+ action := 'delete';
60
+ changes := to_jsonb(old);
61
+ else
62
+ row_id := new.id;
63
+ action := 'update';
64
+ select jsonb_object_agg(o.key, jsonb_build_object('from', o.value, 'to', n.value)) into changes
65
+ from jsonb_each(to_jsonb(old)) o join jsonb_each(to_jsonb(new)) n on n.key = o.key
66
+ where o.value is distinct from n.value and o.key <> 'updated_at';
67
+ -- An update that set every value to what it was changed nothing.
68
+ if changes is null then return null; end if;
69
+ end if;
70
+ insert into light_changes (table_name, row_id, action, changes, actor_kind, actor_id, actor_name)
71
+ values (tg_table_name, row_id, action, changes, actor_kind, actor_id, actor_name);
72
+ return null;
73
+ end $$;
74
+
75
+ create or replace function light_track(tbl text) returns void language plpgsql as $$
76
+ begin
77
+ execute format('create or replace trigger light_record_change after insert or update or delete on %I
78
+ for each row execute function light_record_change()', tbl);
79
+ execute format('create or replace trigger light_record_truncate before truncate on %I
80
+ for each statement execute function light_record_change()', tbl);
81
+ end $$;
82
+
83
+ -- col null: every row of tbl, whole (the table is being dropped).
84
+ create or replace function light_archive_values(tbl text, col text, tag text) returns void language plpgsql as $$
85
+ declare
86
+ id_of_row text := case when exists (
87
+ select 1 from pg_attribute
88
+ where attrelid = format('%I', tbl)::regclass and attname = 'id' and atttypid = 'uuid'::regtype and not attisdropped
89
+ ) then 't.id' else 'null::uuid' end;
90
+ archived bigint;
91
+ begin
92
+ if col is null then
93
+ execute format(
94
+ 'insert into light_archive (table_name, column_name, row_id, value, migration)
95
+ select $1, null, %s, to_jsonb(t), $2 from %I t', id_of_row, tbl)
96
+ using tbl, tag;
97
+ else
98
+ execute format(
99
+ 'insert into light_archive (table_name, column_name, row_id, value, migration)
100
+ select $1, $2, %s, to_jsonb(t)->$2, $3 from %I t where %I is not null', id_of_row, tbl, col)
101
+ using tbl, col, tag;
102
+ end if;
103
+ get diagnostics archived = row_count;
104
+ if archived = 0 then return; end if;
105
+ raise notice using errcode = '${ARCHIVED_NOTICE}', message = format('Archived %s %s from %s (migration %s).',
106
+ archived, case when col is null then 'rows' else 'values' end, concat_ws('.', tbl, col), tag);
107
+ end $$;
108
+ `;
109
+ /** light_changes is a drizzle table (db/schema exports it), so it exists only once migrated. */
110
+ const CHANGES_APPEND_ONLY = `
111
+ create or replace trigger light_append_only before update or delete or truncate on light_changes
112
+ for each statement execute function light_append_only();
113
+ `;
114
+ /** Makes light_archive and the functions migrations and triggers call. Runs before migrating. */
115
+ export async function prepareAudit(sql) {
116
+ await sql.unsafe(PREPARE);
117
+ }
118
+ /**
119
+ * The entity tables `schema` declares. Read before migrating: the triggers
120
+ * write into light_changes, so a schema that doesn't export it is refused
121
+ * before any migration applies.
122
+ */
123
+ export function entityTables(schema) {
124
+ const values = Object.values(schema);
125
+ if (!values.some((value) => is(value, PgTable) && getTableName(value) === getTableName(lightChanges))) {
126
+ throw new Error("db/schema/index.ts must export lightChanges (`export { lightChanges } from \"@trylight/app/schema\";`): it records every change to the app's tables.");
127
+ }
128
+ return values.filter(isEntity).map((entity) => getTableName(entity));
129
+ }
130
+ /**
131
+ * Puts the change trigger on each of `tables` the database has (one a
132
+ * migration hasn't made yet is tracked on the migrate that makes it), and
133
+ * makes light_changes append-only. Runs after migrating.
134
+ */
135
+ export async function trackTables(sql, tables) {
136
+ await sql `select light_track(name) from unnest(${sql.array(tables)}::text[]) as name where to_regclass(quote_ident(name)) is not null`;
137
+ await sql.unsafe(CHANGES_APPEND_ONLY);
138
+ }
139
+ const snapshotTable = z.looseObject({
140
+ name: z.string(),
141
+ columns: z.record(z.string(), z.looseObject({ type: z.string() }))
142
+ });
143
+ /**
144
+ * What going from `previous` to `current` (drizzle-kit snapshots' `tables`)
145
+ * destroys: dropped tables, dropped columns, and columns whose type changes
146
+ * (Postgres converts the values, which may not survive the trip back).
147
+ */
148
+ export function destroyed(previous, current) {
149
+ const found = [];
150
+ for (const [key, entry] of Object.entries(previous)) {
151
+ const before = snapshotTable.parse(entry);
152
+ if (!(key in current)) {
153
+ found.push({ table: before.name, column: null });
154
+ continue;
155
+ }
156
+ const now = snapshotTable.parse(current[key]);
157
+ for (const [name, column] of Object.entries(before.columns)) {
158
+ const after = now.columns[name];
159
+ if (after === undefined || after.type !== column.type)
160
+ found.push({ table: before.name, column: name });
161
+ }
162
+ }
163
+ return found;
164
+ }
165
+ /** A SQL string literal, for the migration file drizzle's migrator runs. */
166
+ function literal(text) {
167
+ return `'${text.replaceAll("'", "''")}'`;
168
+ }
169
+ /** The statements that archive what migration `tag` destroys, to run first in it. */
170
+ export function archiveStatements(archive, tag) {
171
+ return archive.map(({ table, column }) => `SELECT light_archive_values(${literal(table)}, ${column === null ? "NULL" : literal(column)}, ${literal(tag)});`);
172
+ }
173
+ const archivedNotice = z.object({ code: z.literal(ARCHIVED_NOTICE), message: z.string() });
174
+ /**
175
+ * Collects what a migrate archived, to print once its transaction commits
176
+ * (a migration that fails archives nothing). Other notices are dropped.
177
+ */
178
+ export function archiveNotices() {
179
+ const messages = [];
180
+ return {
181
+ onnotice(notice) {
182
+ const archived = archivedNotice.safeParse(notice);
183
+ if (archived.success)
184
+ messages.push(archived.data.message);
185
+ },
186
+ print() {
187
+ if (messages.length === 0)
188
+ return;
189
+ console.log(messages.join("\n"));
190
+ console.log("Archived values stay in light_archive; AGENTS.md, \"Getting deleted data back\", says how to restore them.");
191
+ }
192
+ };
193
+ }
package/dist/db/cli.js CHANGED
@@ -9,6 +9,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
9
9
  import { migrate } from "drizzle-orm/postgres-js/migrator";
10
10
  import postgres from "postgres";
11
11
  import { z } from "zod/v4";
12
+ import { archiveNotices, entityTables, prepareAudit, trackTables } from "./audit.js";
12
13
  import { MIGRATIONS_DIR, SCHEMA_ENTRY, plan, unwriteMigration, writeMigration } from "./migrations.js";
13
14
  import { env } from "../server/env.js";
14
15
  /**
@@ -20,8 +21,10 @@ const PLATFORM_MIGRATE = "/run/mii/migrate.sock";
20
21
  /** Postgres advisory locks: one migrate, and one sync, of a database at a time. */
21
22
  const MIGRATE_LOCK_KEY = 4_815_162_342;
22
23
  const SYNC_LOCK_KEY = 4_815_162_343;
24
+ /** Exit code of a migrate whose migrations committed but whose change triggers didn't go on. */
25
+ const APPLIED_UNTRACKED = 2;
23
26
  // pg_advisory_lock waits for the one before it; a process that dies releases it.
24
- const connect = () => postgres(databaseUrl(), { max: 1, onnotice: () => undefined });
27
+ const connect = (onnotice = () => undefined) => postgres(databaseUrl(), { max: 1, onnotice });
25
28
  const root = process.cwd();
26
29
  const envFile = join(root, ".env.local");
27
30
  if (existsSync(envFile))
@@ -56,7 +59,7 @@ async function sync() {
56
59
  console.error(error instanceof Error ? error.message : String(error));
57
60
  return 1;
58
61
  });
59
- if (applied !== 0) {
62
+ if (applied !== 0 && applied !== APPLIED_UNTRACKED) {
60
63
  unwriteMigration(root, tag);
61
64
  console.error(`${MIGRATIONS_DIR}/${tag}.sql didn't apply, so it was taken back; change db/schema and save again.`);
62
65
  }
@@ -74,17 +77,30 @@ async function check() {
74
77
  console.error(`db/schema has changes with no migration yet; run \`pnpm db:sync\`:\n${planned.statements.join("\n")}`);
75
78
  return 1;
76
79
  }
77
- /** Applies every migration in db/migrations the database doesn't have. */
80
+ /**
81
+ * Applies every migration in db/migrations the database doesn't have, with
82
+ * the audit around them (./audit.ts): its functions first, since a
83
+ * migration's archive step calls them; its triggers after, on the tables
84
+ * migrations made.
85
+ */
78
86
  async function applyMigrations() {
79
87
  // The agent (not root) in an app's sandbox: the platform migrates for it.
80
88
  if (userInfo().uid !== 0 && existsSync(PLATFORM_MIGRATE))
81
89
  return migrateThroughPlatform();
82
- const client = connect();
90
+ const archived = archiveNotices();
91
+ const client = connect(archived.onnotice);
83
92
  try {
84
93
  await client `select pg_advisory_lock(${MIGRATE_LOCK_KEY})`;
94
+ const tables = entityTables(await loadSchema());
95
+ await prepareAudit(client);
85
96
  await migrate(drizzle(client), { migrationsFolder: join(root, MIGRATIONS_DIR) });
97
+ archived.print();
86
98
  console.log("Database is up to date with db/migrations.");
87
- return 0;
99
+ // The migrations are committed: a failure here must not take their files back.
100
+ return await trackTables(client, tables).then(() => 0, (error) => {
101
+ console.error(`The change triggers didn't go on (${error instanceof Error ? error.message : String(error)}); the next migrate tries again.`);
102
+ return APPLIED_UNTRACKED;
103
+ });
88
104
  }
89
105
  finally {
90
106
  await client.end();
@@ -1,9 +1,12 @@
1
1
  import { z } from "zod/v4";
2
+ import { type Destroyed } from "./audit.js";
2
3
  export declare const SCHEMA_ENTRY = "db/schema/index.ts";
3
4
  export declare const MIGRATIONS_DIR = "db/migrations";
4
5
  export type Plan = {
5
6
  /** The new migration's statements, in order; empty when nothing changed. */
6
7
  statements: string[];
8
+ /** What the statements destroy, archived first when the migration runs. */
9
+ archive: Destroyed[];
7
10
  snapshot: Snapshot;
8
11
  };
9
12
  /** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
@@ -30,7 +33,7 @@ type Snapshot = z.infer<typeof snapshotSchema>;
30
33
  * What `db/schema` needs beyond the last migration, in three diffs that each
31
34
  * only remove or only add, so drizzle-kit never has to ask whether something
32
35
  * removed was renamed (it can't ask here: there is no terminal). A rename is
33
- * therefore a drop and an add: the old column's values go. Removed enums go last,
36
+ * therefore a drop and an add: the old column's values are archived. Removed enums go last,
34
37
  * after the columns that used them have changed.
35
38
  */
36
39
  export declare function plan(root: string, schema: Record<string, unknown>): Promise<Plan>;
@@ -1,12 +1,13 @@
1
1
  // Writing a schema file is the migration: the change from the last
2
2
  // migration's snapshot to `db/schema` becomes a new drizzle migration,
3
3
  // written and applied at once, whatever it does to the rows there (a removed
4
- // column's values go with it).
4
+ // column's values are archived first, in light_archive).
5
5
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { isDeepStrictEqual } from "node:util";
7
7
  import { join } from "node:path";
8
8
  import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
9
9
  import { z } from "zod/v4";
10
+ import { archiveStatements, destroyed } from "./audit.js";
10
11
  export const SCHEMA_ENTRY = "db/schema/index.ts";
11
12
  export const MIGRATIONS_DIR = "db/migrations";
12
13
  const entries = z.record(z.string(), z.unknown());
@@ -33,7 +34,7 @@ const snapshotSchema = z.looseObject({
33
34
  * What `db/schema` needs beyond the last migration, in three diffs that each
34
35
  * only remove or only add, so drizzle-kit never has to ask whether something
35
36
  * removed was renamed (it can't ask here: there is no terminal). A rename is
36
- * therefore a drop and an add: the old column's values go. Removed enums go last,
37
+ * therefore a drop and an add: the old column's values are archived. Removed enums go last,
37
38
  * after the columns that used them have changed.
38
39
  */
39
40
  export async function plan(root, schema) {
@@ -47,7 +48,7 @@ export async function plan(root, schema) {
47
48
  ...(await generateMigration(kept, withOldEnums)),
48
49
  ...(await generateMigration(withOldEnums, snapshot))
49
50
  ];
50
- return { statements, snapshot };
51
+ return { statements, archive: destroyed(previous.tables, snapshot.tables), snapshot };
51
52
  }
52
53
  function only(from, keep) {
53
54
  return Object.fromEntries(Object.entries(from).filter(([key]) => key in keep));
@@ -139,7 +140,8 @@ export function writeMigration(root, planned) {
139
140
  const idx = journal.entries.length;
140
141
  const tag = `${String(idx).padStart(4, "0")}_schema`;
141
142
  mkdirSync(join(root, MIGRATIONS_DIR, "meta"), { recursive: true });
142
- writeFileSync(join(root, MIGRATIONS_DIR, `${tag}.sql`), planned.statements.join("\n--> statement-breakpoint\n") + "\n");
143
+ const statements = [...archiveStatements(planned.archive, tag), ...planned.statements];
144
+ writeFileSync(join(root, MIGRATIONS_DIR, `${tag}.sql`), statements.join("\n--> statement-breakpoint\n") + "\n");
143
145
  writeFileSync(snapshotFile(root, idx), JSON.stringify(planned.snapshot, null, 2) + "\n");
144
146
  journal.entries.push({ idx, version: "7", when: Date.now(), tag, breakpoints: true });
145
147
  writeFileSync(join(root, MIGRATIONS_DIR, "meta", "_journal.json"), JSON.stringify(journal, null, 2) + "\n");
@@ -0,0 +1,7 @@
1
+ export declare const IMAGE_PATH = "/api/light/image";
2
+ /**
3
+ * Where a row's photo or logo loads from: the app's own copy (kept from the
4
+ * first view on) for an address on the web, the value itself for one of the
5
+ * app's own files.
6
+ */
7
+ export declare function imageSrc(src: string): string;
package/dist/images.js ADDED
@@ -0,0 +1,12 @@
1
+ // What the browser and the image route agree on: where a row's photo or logo
2
+ // loads from. Nothing here may touch a database or the DOM.
3
+ import { webUrl } from "./schema.js";
4
+ export const IMAGE_PATH = "/api/light/image";
5
+ /**
6
+ * Where a row's photo or logo loads from: the app's own copy (kept from the
7
+ * first view on) for an address on the web, the value itself for one of the
8
+ * app's own files.
9
+ */
10
+ export function imageSrc(src) {
11
+ return webUrl.safeParse(src).success ? `${IMAGE_PATH}?src=${encodeURIComponent(src)}` : src;
12
+ }
package/dist/schema.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { PgTable, type PgColumnBuilderBase, type PgTableWithColumns, type PgTableExtraConfigValue } from "drizzle-orm/pg-core";
2
2
  import type { BuildColumns, BuildExtraConfigColumns } from "drizzle-orm";
3
3
  import { z } from "zod/v4";
4
+ /** An address on the web: http or https. */
5
+ export declare const webUrl: z.ZodURL;
4
6
  /** Where one value was read: the page, and the words on it that say so. */
5
7
  export declare const fieldSource: z.ZodObject<{
6
8
  url: z.ZodURL;
@@ -73,9 +75,11 @@ export type Entity = PgTable & {
73
75
  };
74
76
  export declare function isEntity(value: unknown): value is Entity;
75
77
  /**
76
- * Every change a person makes through `app.table(...)`, with who made it, in
77
- * the same transaction as the change. `changes` is the new row for a create,
78
- * the old row for a delete, and `{ column: { from, to } }` for an update.
78
+ * Every change to an entity table, whoever made it, written by the table's
79
+ * trigger (src/db/audit.ts) in the change's transaction. `changes` is the new
80
+ * row for a create, the old row for a delete, and `{ column: { from, to } }`
81
+ * for an update. A `database` actor is a writer that didn't name itself: its
82
+ * name is the database role.
79
83
  */
80
84
  export declare const lightChanges: PgTableWithColumns<{
81
85
  name: "light_changes";
@@ -173,14 +177,14 @@ export declare const lightChanges: PgTableWithColumns<{
173
177
  tableName: "light_changes";
174
178
  dataType: "string";
175
179
  columnType: "PgText";
176
- data: "person" | "agent";
180
+ data: "person" | "agent" | "database";
177
181
  driverParam: string;
178
182
  notNull: true;
179
183
  hasDefault: false;
180
184
  isPrimaryKey: false;
181
185
  isAutoincrement: false;
182
186
  hasRuntimeDefault: false;
183
- enumValues: ["person", "agent"];
187
+ enumValues: ["person", "agent", "database"];
184
188
  baseColumn: never;
185
189
  identity: undefined;
186
190
  generated: undefined;
@@ -239,4 +243,86 @@ export declare const lightChanges: PgTableWithColumns<{
239
243
  };
240
244
  dialect: "pg";
241
245
  }>;
246
+ /**
247
+ * The app's own copy of each photo or logo its rows point at, kept on the
248
+ * first view (the image route, `@trylight/app/server`), so a source that
249
+ * moves or shuts down later does not take the picture with it.
250
+ */
251
+ export declare const lightImages: PgTableWithColumns<{
252
+ name: "light_images";
253
+ schema: undefined;
254
+ columns: {
255
+ source_url: import("drizzle-orm/pg-core").PgColumn<{
256
+ name: "source_url";
257
+ tableName: "light_images";
258
+ dataType: "string";
259
+ columnType: "PgText";
260
+ data: string;
261
+ driverParam: string;
262
+ notNull: true;
263
+ hasDefault: false;
264
+ isPrimaryKey: true;
265
+ isAutoincrement: false;
266
+ hasRuntimeDefault: false;
267
+ enumValues: [string, ...string[]];
268
+ baseColumn: never;
269
+ identity: undefined;
270
+ generated: undefined;
271
+ }, {}, {}>;
272
+ content_type: import("drizzle-orm/pg-core").PgColumn<{
273
+ name: "content_type";
274
+ tableName: "light_images";
275
+ dataType: "string";
276
+ columnType: "PgText";
277
+ data: string;
278
+ driverParam: string;
279
+ notNull: true;
280
+ hasDefault: false;
281
+ isPrimaryKey: false;
282
+ isAutoincrement: false;
283
+ hasRuntimeDefault: false;
284
+ enumValues: [string, ...string[]];
285
+ baseColumn: never;
286
+ identity: undefined;
287
+ generated: undefined;
288
+ }, {}, {}>;
289
+ bytes: import("drizzle-orm/pg-core").PgColumn<{
290
+ name: "bytes";
291
+ tableName: "light_images";
292
+ dataType: "custom";
293
+ columnType: "PgCustomColumn";
294
+ data: Uint8Array<ArrayBufferLike>;
295
+ driverParam: unknown;
296
+ notNull: true;
297
+ hasDefault: false;
298
+ isPrimaryKey: false;
299
+ isAutoincrement: false;
300
+ hasRuntimeDefault: false;
301
+ enumValues: undefined;
302
+ baseColumn: never;
303
+ identity: undefined;
304
+ generated: undefined;
305
+ }, {}, {
306
+ pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
307
+ }>;
308
+ fetched_at: import("drizzle-orm/pg-core").PgColumn<{
309
+ name: "fetched_at";
310
+ tableName: "light_images";
311
+ dataType: "string";
312
+ columnType: "PgTimestampString";
313
+ data: string;
314
+ driverParam: string;
315
+ notNull: true;
316
+ hasDefault: true;
317
+ isPrimaryKey: false;
318
+ isAutoincrement: false;
319
+ hasRuntimeDefault: false;
320
+ enumValues: undefined;
321
+ baseColumn: never;
322
+ identity: undefined;
323
+ generated: undefined;
324
+ }, {}, {}>;
325
+ };
326
+ dialect: "pg";
327
+ }>;
242
328
  export {};
package/dist/schema.js CHANGED
@@ -3,11 +3,13 @@
3
3
  // the app. Imported by drizzle-kit and the browser's types, so nothing here
4
4
  // may touch a database or the DOM.
5
5
  import { is } from "drizzle-orm";
6
- import { PgTable, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
6
+ import { PgTable, customType, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
7
7
  import { z } from "zod/v4";
8
+ /** An address on the web: http or https. */
9
+ export const webUrl = z.url({ protocol: /^https?$/ });
8
10
  /** Where one value was read: the page, and the words on it that say so. */
9
11
  export const fieldSource = z.object({
10
- url: z.url({ protocol: /^https?$/ }),
12
+ url: webUrl,
11
13
  evidence: z.string().min(1)
12
14
  });
13
15
  /** The site a source's url names, as people read it: "https://www.acme.com/about" is "acme.com". */
@@ -62,9 +64,11 @@ export function isEntity(value) {
62
64
  return is(value, PgTable) && "$access" in value;
63
65
  }
64
66
  /**
65
- * Every change a person makes through `app.table(...)`, with who made it, in
66
- * the same transaction as the change. `changes` is the new row for a create,
67
- * the old row for a delete, and `{ column: { from, to } }` for an update.
67
+ * Every change to an entity table, whoever made it, written by the table's
68
+ * trigger (src/db/audit.ts) in the change's transaction. `changes` is the new
69
+ * row for a create, the old row for a delete, and `{ column: { from, to } }`
70
+ * for an update. A `database` actor is a writer that didn't name itself: its
71
+ * name is the database role.
68
72
  */
69
73
  export const lightChanges = pgTable("light_changes", {
70
74
  id: uuid().primaryKey().defaultRandom(),
@@ -72,10 +76,22 @@ export const lightChanges = pgTable("light_changes", {
72
76
  row_id: uuid().notNull(),
73
77
  action: text({ enum: ["create", "update", "delete"] }).notNull(),
74
78
  changes: jsonb().$type().notNull(),
75
- actor_kind: text({ enum: ["person", "agent"] }).notNull(),
79
+ actor_kind: text({ enum: ["person", "agent", "database"] }).notNull(),
76
80
  actor_id: text(),
77
81
  actor_name: text().notNull(),
78
82
  created_at: timestamp({ withTimezone: true, mode: "string" })
79
83
  .notNull()
80
84
  .defaultNow()
81
85
  }, (t) => [index("light_changes_row_idx").on(t.table_name, t.row_id)]);
86
+ const bytea = customType({ dataType: () => "bytea" });
87
+ /**
88
+ * The app's own copy of each photo or logo its rows point at, kept on the
89
+ * first view (the image route, `@trylight/app/server`), so a source that
90
+ * moves or shuts down later does not take the picture with it.
91
+ */
92
+ export const lightImages = pgTable("light_images", {
93
+ source_url: text().primaryKey(),
94
+ content_type: text().notNull(),
95
+ bytes: bytea().notNull(),
96
+ fetched_at: timestamp({ withTimezone: true, mode: "string" }).notNull().defaultNow()
97
+ });
@@ -1,14 +1,14 @@
1
1
  // The one route behind `app.table(...)`: list, get, create, update and delete
2
2
  // on any entity table, each request checked against the table's own Zod
3
- // schema and the app's door, and every change recorded in light_changes with
4
- // who made it, in the same transaction.
5
- import { and, asc, desc, eq, getTableColumns, getTableName, inArray, isNull } from "drizzle-orm";
3
+ // schema and the app's door. Each write names its person for the table's
4
+ // trigger, which records the change in light_changes (src/db/audit.ts).
5
+ import { and, asc, desc, eq, getTableColumns, getTableName, inArray, isNull, sql } from "drizzle-orm";
6
6
  import { isDeepStrictEqual } from "node:util";
7
7
  import { createSchemaFactory } from "drizzle-zod";
8
8
  import { isSameOrigin } from "@shubh90/app-runtime/chat/server";
9
9
  import { z } from "zod/v4";
10
10
  import { ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
11
- import { PLATFORM_COLUMNS, fieldSource, isEntity, lightChanges } from "../schema.js";
11
+ import { PLATFORM_COLUMNS, fieldSource, isEntity } from "../schema.js";
12
12
  import { env } from "./env.js";
13
13
  /** Who writes when the app runs with no sign-in at all: a bare local dev server. */
14
14
  const LOCAL_DEVELOPER = { id: null, name: "Local developer" };
@@ -153,54 +153,39 @@ async function get(db, table, id) {
153
153
  }
154
154
  async function create(db, table, actor, data) {
155
155
  return db.transaction(async (tx) => {
156
- const row = exactlyOne(await tx
156
+ await nameActor(tx, actor);
157
+ return exactlyOne(await tx
157
158
  .insert(table.entity)
158
159
  .values({ ...data, created_by: actor.id })
159
160
  .returning());
160
- await audit(tx, table, rowId(row), "create", row, actor);
161
- return row;
162
161
  });
163
162
  }
164
163
  async function update(db, table, actor, id, patch) {
165
164
  return db.transaction(async (tx) => {
165
+ await nameActor(tx, actor);
166
166
  const before = atMostOne(await tx.select().from(table.entity).where(eq(table.id, id)).for("update"));
167
167
  if (before === null)
168
168
  return null;
169
169
  const changed = Object.keys(patch).filter((key) => !isDeepStrictEqual(patch[key], before[key]));
170
170
  // A value a person sets is no longer what its source said; sources sent are merged in.
171
171
  const field_sources = { ...withoutSources(before.field_sources, changed), ...patch.field_sources };
172
- const row = exactlyOne(await tx
172
+ return exactlyOne(await tx
173
173
  .update(table.entity)
174
174
  .set({ ...patch, field_sources })
175
175
  .where(eq(table.id, id))
176
176
  .returning());
177
- const changes = Object.fromEntries([...changed, "field_sources"]
178
- .filter((key) => !isDeepStrictEqual(before[key], row[key]))
179
- .map((key) => [key, { from: before[key], to: row[key] }]));
180
- if (Object.keys(changes).length > 0)
181
- await audit(tx, table, id, "update", changes, actor);
182
- return row;
183
177
  });
184
178
  }
185
179
  async function remove(db, table, actor, id) {
186
180
  return db.transaction(async (tx) => {
181
+ await nameActor(tx, actor);
187
182
  const row = atMostOne(await tx.delete(table.entity).where(eq(table.id, id)).returning());
188
- if (row === null)
189
- return null;
190
- await audit(tx, table, id, "delete", row, actor);
191
- return { id };
183
+ return row === null ? null : { id };
192
184
  });
193
185
  }
194
- function audit(tx, table, rowId, action, changes, actor) {
195
- return tx.insert(lightChanges).values({
196
- table_name: table.name,
197
- row_id: rowId,
198
- action,
199
- changes,
200
- actor_kind: "person",
201
- actor_id: actor.id,
202
- actor_name: actor.name
203
- });
186
+ /** Who the table's trigger records in light_changes for the rest of this transaction. */
187
+ function nameActor(tx, actor) {
188
+ return tx.execute(sql `select set_config('light.actor_kind', 'person', true), set_config('light.actor_id', coalesce(${actor.id}, ''), true), set_config('light.actor_name', ${actor.name}, true)`);
204
189
  }
205
190
  /** The row a statement by primary key touched, or null when there is none. */
206
191
  function atMostOne(rows) {
@@ -218,10 +203,6 @@ function exactlyOne(rows) {
218
203
  }
219
204
  // What's stored may come from an agent's own write: read as it is, never refused here.
220
205
  const storedSources = z.record(z.string(), z.unknown());
221
- const storedId = z.object({ id: z.uuid() });
222
- function rowId(row) {
223
- return storedId.parse(row).id;
224
- }
225
206
  function withoutSources(stored, columns) {
226
207
  const sources = storedSources.parse(stored);
227
208
  return Object.fromEntries(Object.entries(sources).filter(([column]) => !columns.includes(column)));
@@ -0,0 +1,40 @@
1
+ import { type LookupFunction } from "node:net";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { SessionState } from "./session.js";
4
+ type Db = PostgresJsDatabase<Record<string, unknown>>;
5
+ export declare const MAX_IMAGE_BYTES: number;
6
+ export type StoredImage = {
7
+ readonly contentType: string;
8
+ readonly bytes: Uint8Array;
9
+ };
10
+ export type ImageLookup = {
11
+ readonly ok: true;
12
+ readonly image: StoredImage;
13
+ } | {
14
+ readonly ok: false;
15
+ readonly reason: string;
16
+ };
17
+ export type ImageOptions = {
18
+ db: () => Db;
19
+ session: (request: Request) => Promise<SessionState>;
20
+ };
21
+ /** `/api/light/image?src=…`: a photo or logo a row points at, served from the app's own copy. */
22
+ export declare function createImageHandler(options: ImageOptions): (request: Request) => Promise<Response>;
23
+ /**
24
+ * An address on the public internet: not this machine, a private or carrier
25
+ * network, link-local (the cloud metadata address), multicast or reserved.
26
+ * An IPv4 address written as IPv6 is judged as itself.
27
+ */
28
+ export declare function isPublicAddress(address: string): boolean;
29
+ /**
30
+ * The name lookup of every connection the image fetch opens, redirects
31
+ * included: the address checked is the address connected to, so a public
32
+ * name that resolves (or is re-pointed between two asks) to this machine or
33
+ * a private network is never reached.
34
+ */
35
+ export declare const publicOnlyLookup: LookupFunction;
36
+ /** The app's copy of the image at `source`, if a member's view has kept one. */
37
+ export declare function keptImage(db: Db, source: URL): Promise<ImageLookup>;
38
+ /** The app's copy of the image at `source`, fetched and kept on the first ask. */
39
+ export declare function storedImage(db: Db, source: URL): Promise<ImageLookup>;
40
+ export {};
@@ -0,0 +1,246 @@
1
+ // The app's copy of the photos and logos its rows point at. The first time a
2
+ // member's browser asks the image route for one it is fetched and kept in
3
+ // light_images; every later ask is answered from there, so a source that
4
+ // rate-limits, moves or shuts down after that does not take the picture with
5
+ // it. Ported from the v1 template (#1063).
6
+ import dns from "node:dns";
7
+ import { isIP } from "node:net";
8
+ import { eq } from "drizzle-orm";
9
+ import ipaddr from "ipaddr.js";
10
+ import { Agent } from "undici";
11
+ import { z } from "zod/v4";
12
+ import { lightImages, webUrl } from "../schema.js";
13
+ import { env } from "./env.js";
14
+ const FETCH_TIMEOUT_MS = 15_000;
15
+ const MAX_REDIRECTS = 5;
16
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
17
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
18
+ const YEAR_SECONDS = 365 * 24 * 60 * 60;
19
+ // An SVG is a document if someone navigates to it; the route's sandbox
20
+ // header keeps it inert there, and in an <img> it never runs script.
21
+ const IMAGE_TYPES = new Set([
22
+ "image/png",
23
+ "image/jpeg",
24
+ "image/gif",
25
+ "image/webp",
26
+ "image/avif",
27
+ "image/svg+xml",
28
+ "image/x-icon",
29
+ "image/vnd.microsoft.icon"
30
+ ]);
31
+ // The bytes are an image or they are nothing, and an SVG opened on its own stays inert.
32
+ const INERT = {
33
+ "Content-Security-Policy": "sandbox",
34
+ "X-Content-Type-Options": "nosniff"
35
+ };
36
+ // Node's fetch reports a DNS or connection failure as "fetch failed", with the
37
+ // reason (ENOTFOUND, ECONNREFUSED…) only on its cause.
38
+ const networkFailure = z.object({ cause: z.object({ code: z.string() }) });
39
+ /** `/api/light/image?src=…`: a photo or logo a row points at, served from the app's own copy. */
40
+ export function createImageHandler(options) {
41
+ return async (request) => {
42
+ const { user, mode } = await options.session(request);
43
+ if (user === null && mode === "members") {
44
+ return new Response("Sign in to use this app.", { status: 401 });
45
+ }
46
+ const src = webUrl.safeParse(new URL(request.url).searchParams.get("src"));
47
+ if (!src.success) {
48
+ return new Response("src must be an http(s) address.", { status: 400 });
49
+ }
50
+ const db = options.db();
51
+ const source = new URL(src.data);
52
+ // A stranger to a public app only reads the copy, so nobody signed out can
53
+ // make the app fetch or keep anything; a member's view fills it, as does
54
+ // every view where there is no sign-in to be a stranger to.
55
+ const stranger = user === null && env().configured;
56
+ const lookup = stranger ? keptImage : storedImage;
57
+ const found = await lookup(db, source);
58
+ if (!found.ok) {
59
+ // Nothing is kept for a source that did not answer with an image, so the
60
+ // next view asks it again; until then the row shows its letters.
61
+ return new Response(found.reason, {
62
+ status: 404,
63
+ headers: { ...INERT, "Cache-Control": "no-store" }
64
+ });
65
+ }
66
+ // Cached by the CDN under the source address, which only someone who
67
+ // already knows it can ask for; the pictures are the public web's.
68
+ // A copy: the driver's buffer is typed over any ArrayBufferLike, a body needs one over ArrayBuffer.
69
+ return new Response(new Uint8Array(found.image.bytes), {
70
+ headers: {
71
+ ...INERT,
72
+ "Content-Type": found.image.contentType,
73
+ "Cache-Control": `public, max-age=${YEAR_SECONDS}, immutable`
74
+ }
75
+ });
76
+ };
77
+ }
78
+ /**
79
+ * An address on the public internet: not this machine, a private or carrier
80
+ * network, link-local (the cloud metadata address), multicast or reserved.
81
+ * An IPv4 address written as IPv6 is judged as itself.
82
+ */
83
+ export function isPublicAddress(address) {
84
+ return ipaddr.isValid(address) && ipaddr.process(address).range() === "unicast";
85
+ }
86
+ class NotPublicAddress extends Error {
87
+ address;
88
+ code = "ENOTPUBLIC";
89
+ constructor(address) {
90
+ super(`${address} is not a public address.`);
91
+ this.address = address;
92
+ }
93
+ }
94
+ /**
95
+ * The name lookup of every connection the image fetch opens, redirects
96
+ * included: the address checked is the address connected to, so a public
97
+ * name that resolves (or is re-pointed between two asks) to this machine or
98
+ * a private network is never reached.
99
+ */
100
+ export const publicOnlyLookup = (hostname, options, callback) => {
101
+ dns.lookup(hostname, options, (error, address, family) => {
102
+ if (error !== null)
103
+ return callback(error, address, family);
104
+ const addresses = Array.isArray(address) ? address.map((found) => found.address) : [address];
105
+ const [refused = null] = addresses.filter((found) => !isPublicAddress(found));
106
+ if (refused !== null)
107
+ return callback(new NotPublicAddress(refused), address, family);
108
+ callback(null, address, family);
109
+ });
110
+ };
111
+ // Connects only to public addresses. A bare address in a URL skips the name
112
+ // lookup, so fetchImage checks that one itself.
113
+ const publicWeb = new Agent({ connect: { lookup: publicOnlyLookup } });
114
+ const refusedAddress = z.object({ cause: z.instanceof(NotPublicAddress) });
115
+ // A fragment never reaches the server, so two addresses that differ only
116
+ // there are one image.
117
+ function keyOf(source) {
118
+ const key = new URL(source);
119
+ key.hash = "";
120
+ return key;
121
+ }
122
+ /** The app's copy of the image at `source`, if a member's view has kept one. */
123
+ export async function keptImage(db, source) {
124
+ const [kept = null] = await db
125
+ .select({ contentType: lightImages.content_type, bytes: lightImages.bytes })
126
+ .from(lightImages)
127
+ .where(eq(lightImages.source_url, keyOf(source).href));
128
+ if (kept === null)
129
+ return { ok: false, reason: "No copy of this image is kept yet." };
130
+ return { ok: true, image: kept };
131
+ }
132
+ /** The app's copy of the image at `source`, fetched and kept on the first ask. */
133
+ export async function storedImage(db, source) {
134
+ const kept = await keptImage(db, source);
135
+ if (kept.ok)
136
+ return kept;
137
+ const key = keyOf(source);
138
+ const fetched = await fetchImage(key);
139
+ if (!fetched.ok)
140
+ return fetched;
141
+ await db
142
+ .insert(lightImages)
143
+ .values({ source_url: key.href, content_type: fetched.image.contentType, bytes: fetched.image.bytes })
144
+ .onConflictDoNothing();
145
+ return fetched;
146
+ }
147
+ function failureReason(error) {
148
+ const failure = networkFailure.safeParse(error);
149
+ return failure.success ? failure.data.cause.code : String(error);
150
+ }
151
+ async function fetchImage(source) {
152
+ const host = source.hostname.toLowerCase();
153
+ // Each hop is checked: a redirect must never be the way to make this app
154
+ // fetch from somewhere it would not fetch itself.
155
+ let url = source;
156
+ let response;
157
+ for (let hop = 0;; hop += 1) {
158
+ const at = url.hostname.toLowerCase();
159
+ const notPublic = (detail) => ({
160
+ ok: false,
161
+ reason: hop === 0
162
+ ? `${at} is not a public web address${detail}.`
163
+ : `${host} redirected to ${at}, which is not a public web address${detail}.`
164
+ });
165
+ // An IPv6 host is bracketed in a URL.
166
+ const bare = at.replace(/^\[(.*)\]$/, "$1");
167
+ if (isIP(bare) !== 0 && !isPublicAddress(bare))
168
+ return notPublic("");
169
+ const init = {
170
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
171
+ redirect: "manual",
172
+ dispatcher: publicWeb
173
+ };
174
+ try {
175
+ response = await fetch(url, init);
176
+ }
177
+ catch (error) {
178
+ const refused = refusedAddress.safeParse(error);
179
+ if (refused.success)
180
+ return notPublic(` (it resolves to ${refused.data.cause.address})`);
181
+ return { ok: false, reason: `${at} could not be reached (${failureReason(error)}).` };
182
+ }
183
+ const location = response.headers.get("location");
184
+ if (location === null || !REDIRECT_STATUSES.has(response.status))
185
+ break;
186
+ if (response.body !== null)
187
+ await response.body.cancel();
188
+ if (hop === MAX_REDIRECTS)
189
+ return { ok: false, reason: `${host} redirected too many times.` };
190
+ url = new URL(location, url);
191
+ }
192
+ if (!response.ok)
193
+ return { ok: false, reason: `${host} answered HTTP ${response.status}.` };
194
+ const contentType = mediaType(response.headers.get("content-type"));
195
+ if (!IMAGE_TYPES.has(contentType)) {
196
+ return { ok: false, reason: `${host} answered with ${contentType || "no content type"}, not an image.` };
197
+ }
198
+ let bytes;
199
+ try {
200
+ bytes = await readCapped(response, MAX_IMAGE_BYTES);
201
+ }
202
+ catch (error) {
203
+ return { ok: false, reason: `${host} stopped answering before the image was read (${failureReason(error)}).` };
204
+ }
205
+ if (bytes === null)
206
+ return { ok: false, reason: `${host}'s image is over ${MAX_IMAGE_BYTES} bytes.` };
207
+ // An empty 200 is what a rate-limiter or a CDN edge answers with; kept, it
208
+ // would be served as the picture for a year.
209
+ if (bytes.byteLength === 0)
210
+ return { ok: false, reason: `${host} answered with an empty body.` };
211
+ return { ok: true, image: { contentType, bytes } };
212
+ }
213
+ /** "image/png; charset=binary" is "image/png"; a response with none has "". */
214
+ function mediaType(header) {
215
+ if (header === null)
216
+ return "";
217
+ return header.replace(/;.*$/, "").trim().toLowerCase();
218
+ }
219
+ // Read at most `limit` bytes, abandoning the response the moment it exceeds
220
+ // them: content-length is advisory, so the cap is applied while reading.
221
+ async function readCapped(response, limit) {
222
+ const stream = response.body;
223
+ if (stream === null)
224
+ return new Uint8Array(0);
225
+ const reader = stream.getReader();
226
+ const chunks = [];
227
+ let total = 0;
228
+ for (;;) {
229
+ const { done, value } = await reader.read();
230
+ if (done)
231
+ break;
232
+ total += value.byteLength;
233
+ if (total > limit) {
234
+ await reader.cancel();
235
+ return null;
236
+ }
237
+ chunks.push(value);
238
+ }
239
+ const out = new Uint8Array(total);
240
+ let at = 0;
241
+ for (const chunk of chunks) {
242
+ out.set(chunk, at);
243
+ at += chunk.byteLength;
244
+ }
245
+ return out;
246
+ }
@@ -18,6 +18,8 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
18
18
  readonly db: PostgresJsDatabase<S>;
19
19
  /** `/api/light/entities/*`: what `app.table(...)` calls. */
20
20
  entities: (request: Request) => Promise<Response>;
21
+ /** `/api/light/image`: a row's photo or logo, from the app's own copy (kept on the first view). */
22
+ image: (request: Request) => Promise<Response>;
21
23
  /** Server-side starts and reads of the app's contracts. */
22
24
  invoke: import("@shubh90/app-runtime/invoke/server").MiiInvoke<C>;
23
25
  /** `/api/mii/invoke/*`: the contract buttons, forwarded to Plaza as the person. */
@@ -1,6 +1,6 @@
1
1
  // The app's server half, bound once in `src/server/light.ts`: its database,
2
2
  // its door (sign-in through Plaza), and the routes the SDK serves (entities,
3
- // contracts, chat). Server-only.
3
+ // images, contracts, chat). Server-only.
4
4
  import { drizzle } from "drizzle-orm/postgres-js";
5
5
  import postgres from "postgres";
6
6
  import { createMiiAuthCore } from "@shubh90/app-runtime/auth/core";
@@ -8,6 +8,7 @@ import { createMiiChatHandler } from "@shubh90/app-runtime/chat/server";
8
8
  import { createMiiInvoke } from "@shubh90/app-runtime/invoke/server";
9
9
  import { createEntitiesHandler } from "./entities.js";
10
10
  import { env } from "./env.js";
11
+ import { createImageHandler } from "./images.js";
11
12
  import { readSession, signIn } from "./session.js";
12
13
  export { cookieAttributes, safeNext, SESSION_COOKIE } from "@shubh90/app-runtime/auth/core";
13
14
  export { defineContracts, InvocationError } from "@shubh90/app-runtime/invoke/server";
@@ -42,6 +43,8 @@ export function createLightServer(options) {
42
43
  },
43
44
  /** `/api/light/entities/*`: what `app.table(...)` calls. */
44
45
  entities: createEntitiesHandler({ schema: options.schema, db, session }),
46
+ /** `/api/light/image`: a row's photo or logo, from the app's own copy (kept on the first view). */
47
+ image: createImageHandler({ db, session }),
45
48
  /** Server-side starts and reads of the app's contracts. */
46
49
  invoke,
47
50
  /** `/api/mii/invoke/*`: the contract buttons, forwarded to Plaza as the person. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -54,7 +54,9 @@
54
54
  "@shubh90/app-runtime": "^0.10.3",
55
55
  "drizzle-kit": "0.31.11",
56
56
  "drizzle-zod": "^0.8.3",
57
- "tsx": "^4.23.15"
57
+ "ipaddr.js": "^2.5.0",
58
+ "tsx": "^4.23.15",
59
+ "undici": "^7.30.0"
58
60
  },
59
61
  "devDependencies": {
60
62
  "@eslint-community/eslint-plugin-eslint-comments": "^4.8.1",