@fuzdev/fuz_app 0.108.0 → 0.110.0
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 +4 -0
- package/dist/actions/CLAUDE.md +1 -1
- package/dist/auth/CLAUDE.md +7 -6
- package/dist/auth/account_action_specs.d.ts +18 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +14 -1
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +6 -2
- package/dist/auth/audit_emitter.d.ts +9 -5
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.d.ts +7 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +16 -1
- package/dist/auth/bootstrap_account.d.ts +7 -2
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +9 -4
- package/dist/auth/bootstrap_routes.d.ts +12 -4
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +26 -7
- package/dist/auth/daemon_token_middleware.d.ts +7 -69
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +8 -104
- package/dist/auth/deps.d.ts +9 -5
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/middleware.js +1 -1
- package/dist/auth/migrations.d.ts +7 -0
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +25 -8
- package/dist/auth/request_context.d.ts +1 -3
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +2 -5
- package/dist/auth/session_cookie.d.ts +18 -36
- package/dist/auth/session_cookie.d.ts.map +1 -1
- package/dist/auth/session_cookie.js +16 -26
- package/dist/auth/session_middleware.d.ts +1 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +1 -4
- package/dist/auth/session_queries.d.ts +14 -30
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +15 -45
- package/dist/auth/token_lifetime.d.ts +51 -0
- package/dist/auth/token_lifetime.d.ts.map +1 -0
- package/dist/auth/token_lifetime.js +54 -0
- package/dist/db/CLAUDE.md +9 -5
- package/dist/db/cell_audit_queries.d.ts +4 -2
- package/dist/db/cell_audit_queries.d.ts.map +1 -1
- package/dist/db/cell_audit_queries.js +4 -2
- package/dist/db/pg_error.d.ts +12 -0
- package/dist/db/pg_error.d.ts.map +1 -1
- package/dist/db/pg_error.js +15 -1
- package/dist/dev/setup.d.ts +5 -9
- package/dist/dev/setup.d.ts.map +1 -1
- package/dist/dev/setup.js +18 -21
- package/dist/http/CLAUDE.md +78 -18
- package/dist/http/db_routes.d.ts +96 -3
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +263 -46
- package/dist/http/error_schemas.d.ts +12 -1
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +12 -1
- package/dist/http/pending_effects.d.ts +5 -5
- package/dist/http/pending_effects.d.ts.map +1 -1
- package/dist/http/pending_effects.js +1 -1
- package/dist/runtime/deno.d.ts.map +1 -1
- package/dist/runtime/deno.js +27 -1
- package/dist/runtime/deps.d.ts +38 -4
- package/dist/runtime/deps.d.ts.map +1 -1
- package/dist/runtime/fs.d.ts +24 -5
- package/dist/runtime/fs.d.ts.map +1 -1
- package/dist/runtime/fs.js +38 -7
- package/dist/runtime/mock.d.ts +7 -0
- package/dist/runtime/mock.d.ts.map +1 -1
- package/dist/runtime/mock.js +41 -1
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +7 -1
- package/dist/runtime/secure_file.d.ts +71 -0
- package/dist/runtime/secure_file.d.ts.map +1 -0
- package/dist/runtime/secure_file.js +125 -0
- package/dist/server/app_backend.d.ts +6 -5
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +2 -3
- package/dist/testing/CLAUDE.md +36 -1
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +10 -2
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +13 -8
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +10 -2
- package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_grant_role.js +34 -1
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +53 -1
- package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_table.js +2 -1
- package/dist/testing/cross_backend/query_shape.d.ts +10 -0
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -0
- package/dist/testing/cross_backend/query_shape.js +128 -0
- package/dist/testing/cross_backend/setup.js +1 -1
- package/dist/testing/cross_backend/token_lifetime.d.ts +15 -0
- package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -0
- package/dist/testing/cross_backend/token_lifetime.js +99 -0
- package/dist/testing/cross_backend/token_scope_surface.js +1 -1
- package/dist/testing/daemon_token_rotation.d.ts +83 -0
- package/dist/testing/daemon_token_rotation.d.ts.map +1 -0
- package/dist/testing/daemon_token_rotation.js +99 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +15 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +4 -4
- package/dist/ui/AccountSessions.svelte +0 -5
- package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +7 -4
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/AdminSessions.svelte +0 -5
- package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +9 -4
- package/dist/ui/table_state.svelte.d.ts +18 -5
- package/dist/ui/table_state.svelte.d.ts.map +1 -1
- package/dist/ui/table_state.svelte.js +22 -7
- package/dist/ui/ui_format.d.ts.map +1 -1
- package/dist/ui/ui_format.js +4 -0
- package/package.json +1 -1
package/dist/http/db_routes.js
CHANGED
|
@@ -1,25 +1,155 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API route specs for database administration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Allowlist-gated PostgreSQL table browser using `information_schema` —
|
|
5
|
+
* consumers declare `browsable_tables` and the credential floor
|
|
6
|
+
* (`NON_BROWSABLE_TABLES`) is always subtracted. Provides: list tables, view
|
|
7
|
+
* columns/rows (paginated, bytea values placeholdered), delete rows by PK
|
|
8
|
+
* (audited as `db_admin_row_delete`), health check.
|
|
6
9
|
*
|
|
7
10
|
* @module
|
|
8
11
|
*/
|
|
9
12
|
import { z } from 'zod';
|
|
10
13
|
import { get_route_params, get_route_query } from './route_spec.js';
|
|
11
14
|
import { ActingActor } from './auth_shape.js';
|
|
12
|
-
import { ForeignKeyError, ERROR_TABLE_NOT_FOUND, ERROR_TABLE_NO_PRIMARY_KEY, ERROR_ROW_NOT_FOUND, ERROR_FOREIGN_KEY_VIOLATION, ERROR_INVALID_ROUTE_PARAMS, ERROR_DATABASE_CONNECTION_FAILED } from './error_schemas.js';
|
|
15
|
+
import { ForeignKeyError, ERROR_TABLE_NOT_FOUND, ERROR_TABLE_NO_PRIMARY_KEY, ERROR_TABLE_NOT_DELETABLE, ERROR_ROW_NOT_FOUND, ERROR_FOREIGN_KEY_VIOLATION, ERROR_INVALID_ROUTE_PARAMS, ERROR_DATABASE_CONNECTION_FAILED } from './error_schemas.js';
|
|
13
16
|
import { assert_valid_sql_identifier, VALID_SQL_IDENTIFIER } from '../db/sql_identifier.js';
|
|
17
|
+
import { pg_error_code } from '../db/pg_error.js';
|
|
18
|
+
import { get_client_ip } from './client_ip.js';
|
|
19
|
+
import { emit_after_commit } from './pending_effects.js';
|
|
14
20
|
/** Default page size for `GET /tables/:name` rows. */
|
|
15
21
|
export const DB_TABLE_ROWS_DEFAULT_LIMIT = 100;
|
|
16
22
|
/** Maximum page size for `GET /tables/:name` rows. */
|
|
17
23
|
export const DB_TABLE_ROWS_LIMIT_MAX = 1000;
|
|
24
|
+
/**
|
|
25
|
+
* Per-statement timeout applied (`SET LOCAL statement_timeout`) inside the
|
|
26
|
+
* table-list, table-detail, and row-`DELETE` transactions — no single
|
|
27
|
+
* statement outlives it. (Per statement, not per request: the table listing's
|
|
28
|
+
* one-`COUNT(*)`-per-table loop can still take several in sequence.)
|
|
29
|
+
* Milliseconds.
|
|
30
|
+
*/
|
|
31
|
+
export const DB_ADMIN_STATEMENT_TIMEOUT_MS = 5000;
|
|
32
|
+
/**
|
|
33
|
+
* Tables the browser can never expose, whatever `browsable_tables` names —
|
|
34
|
+
* the credential floor, subtracted from the consumer's allowlist and not
|
|
35
|
+
* overridable:
|
|
36
|
+
*
|
|
37
|
+
* - `account` — the one surface that would return the argon2 password-hash
|
|
38
|
+
* corpus in bulk (every other read path excludes the hash by projection).
|
|
39
|
+
* - `auth_session` / `api_token` — credential digests plus per-account
|
|
40
|
+
* session/token metadata.
|
|
41
|
+
* - `bootstrap_lock` — the first-admin gate; its row is load-bearing state,
|
|
42
|
+
* not content.
|
|
43
|
+
*
|
|
44
|
+
* A table on the floor answers exactly like one that doesn't exist (404
|
|
45
|
+
* `table_not_found`) so the browser doesn't confirm its presence.
|
|
46
|
+
*/
|
|
47
|
+
export const NON_BROWSABLE_TABLES = Object.freeze([
|
|
48
|
+
'account',
|
|
49
|
+
'auth_session',
|
|
50
|
+
'api_token',
|
|
51
|
+
'bootstrap_lock'
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* Tables this endpoint refuses to delete rows from, whatever their key shape.
|
|
55
|
+
*
|
|
56
|
+
* Two kinds, one rule — a generic storage endpoint has no business deleting a
|
|
57
|
+
* row whose meaning lives in the domain layer:
|
|
58
|
+
*
|
|
59
|
+
* - `audit_log` — the trail and the tamper path would otherwise be the same
|
|
60
|
+
* surface. It is also how revocation propagates: `realtime/sse_auth_guard.ts`
|
|
61
|
+
* and the WS auth guard close live streams by listening to audit events, so a
|
|
62
|
+
* raw row delete here is invisible to them.
|
|
63
|
+
* `bootstrap_lock` — deleting the singleton leaves `check_bootstrap_status`
|
|
64
|
+
* advertising an open bootstrap window on the next boot that
|
|
65
|
+
* `bootstrap_account.ts`'s atomic flip then always refuses.
|
|
66
|
+
* - `app_settings` — deleting the singleton makes every settings read throw.
|
|
67
|
+
* - `schema_version` — the migration tracker. Already refused by the
|
|
68
|
+
* single-column-primary-key rule (its key is `(namespace, name)`), but that
|
|
69
|
+
* is incidental: naming it here keeps the protection if the key shape ever
|
|
70
|
+
* changes.
|
|
71
|
+
*
|
|
72
|
+
* Consumers extend this with `DbRouteOptions.non_deletable_tables` — the
|
|
73
|
+
* builtin set is a floor, never replaced.
|
|
74
|
+
*/
|
|
75
|
+
export const NON_DELETABLE_TABLES = Object.freeze([
|
|
76
|
+
'audit_log',
|
|
77
|
+
'bootstrap_lock',
|
|
78
|
+
'app_settings',
|
|
79
|
+
'schema_version'
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a table's primary-key column names in key order
|
|
83
|
+
* (`key_column_usage.ordinal_position`). Empty for a table with no primary key;
|
|
84
|
+
* length `> 1` for a composite primary key.
|
|
85
|
+
*
|
|
86
|
+
* Every column is returned (no `LIMIT`) so callers see the full key shape: the
|
|
87
|
+
* row-`DELETE` route acts only on a single-column primary key, and an earlier
|
|
88
|
+
* `LIMIT 1` (no `ORDER BY`) hid a composite PK behind one arbitrary column,
|
|
89
|
+
* causing single-column deletes to over-match.
|
|
90
|
+
*/
|
|
91
|
+
const query_primary_key_columns = async (db, name) => {
|
|
92
|
+
const pk_rows = await db.query(`SELECT kcu.column_name
|
|
93
|
+
FROM information_schema.table_constraints tc
|
|
94
|
+
JOIN information_schema.key_column_usage kcu
|
|
95
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
96
|
+
AND tc.table_schema = kcu.table_schema
|
|
97
|
+
AND tc.table_name = kcu.table_name
|
|
98
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
99
|
+
AND tc.table_schema = 'public'
|
|
100
|
+
AND tc.table_name = $1
|
|
101
|
+
ORDER BY kcu.ordinal_position`, [name]);
|
|
102
|
+
return pk_rows.map((row) => row.column_name);
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Whether `name` is a table in the `public` schema. Both the detail and
|
|
106
|
+
* row-`DELETE` routes gate on this before interpolating the name into SQL, so
|
|
107
|
+
* an unknown table is a 404 rather than a raw PG error.
|
|
108
|
+
*/
|
|
109
|
+
const query_table_exists = async (db, name) => {
|
|
110
|
+
const row = await db.query_one(`SELECT table_name FROM information_schema.tables
|
|
111
|
+
WHERE table_schema = 'public' AND table_name = $1`, [name]);
|
|
112
|
+
return row !== undefined;
|
|
113
|
+
};
|
|
18
114
|
/**
|
|
19
115
|
* Create the db API route specs.
|
|
20
116
|
*/
|
|
21
|
-
export const create_db_route_specs = (options) => {
|
|
22
|
-
const { db_type, db_name, extra_stats, log } = options;
|
|
117
|
+
export const create_db_route_specs = (deps, options) => {
|
|
118
|
+
const { db_type, db_name, browsable_tables, extra_stats, log, non_deletable_tables } = options;
|
|
119
|
+
const non_deletable = new Set([
|
|
120
|
+
...NON_DELETABLE_TABLES,
|
|
121
|
+
...(non_deletable_tables ?? [])
|
|
122
|
+
]);
|
|
123
|
+
// The credential floor is subtracted, not merely documented — a consumer
|
|
124
|
+
// naming `account` in its allowlist still gets no `account` browsing.
|
|
125
|
+
const browsable = new Set(browsable_tables.filter((name) => !NON_BROWSABLE_TABLES.includes(name)));
|
|
126
|
+
// `SET` can't be parameterized; the value is a module constant, never input.
|
|
127
|
+
const set_statement_timeout = async (db) => {
|
|
128
|
+
await db.query(`SET LOCAL statement_timeout = ${DB_ADMIN_STATEMENT_TIMEOUT_MS}`);
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* The shared masked gate prologue for the detail and row-`DELETE`
|
|
132
|
+
* handlers: allowlist check (before any catalog read), then the statement
|
|
133
|
+
* timeout, then existence — an unbrowsable table and a missing one get
|
|
134
|
+
* the same 404 from the same place, so the masking invariant has one
|
|
135
|
+
* home. Returns whether the table is browsable AND exists.
|
|
136
|
+
*/
|
|
137
|
+
const gate_browsable_table = async (db, name) => {
|
|
138
|
+
if (!browsable.has(name))
|
|
139
|
+
return false;
|
|
140
|
+
await set_statement_timeout(db);
|
|
141
|
+
return query_table_exists(db, name);
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Strict integer query param — accepts exactly what the Rust twin's
|
|
145
|
+
* `str::parse::<i64>` does (`[+-]?digits`), so the twins refuse the same
|
|
146
|
+
* spellings (`z.coerce` alone admits `""`, `" 5"`, `"1e2"`, `"5.0"`).
|
|
147
|
+
*/
|
|
148
|
+
const query_int = (bounds, fallback) => z.preprocess((v) => v === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: typeof v === 'string' && /^[+-]?\d+$/.test(v)
|
|
151
|
+
? Number(v)
|
|
152
|
+
: Number.NaN, bounds.default(fallback));
|
|
23
153
|
return [
|
|
24
154
|
{
|
|
25
155
|
method: 'GET',
|
|
@@ -40,6 +170,9 @@ export const create_db_route_specs = (options) => {
|
|
|
40
170
|
handler: async (c, route) => {
|
|
41
171
|
try {
|
|
42
172
|
await route.db.query('SELECT 1');
|
|
173
|
+
// Deliberately schema-wide (not allowlist-filtered): the count
|
|
174
|
+
// is a did-migrations-run diagnostic, and a bare number leaks
|
|
175
|
+
// no table contents or names.
|
|
43
176
|
const table_result = await route.db.query(`SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
44
177
|
const table_count = table_result[0] ? parseInt(table_result[0].count, 10) : 0;
|
|
45
178
|
const stats = extra_stats ? await extra_stats(route.db) : {};
|
|
@@ -70,18 +203,24 @@ export const create_db_route_specs = (options) => {
|
|
|
70
203
|
roles: ['keeper'],
|
|
71
204
|
credential_types: ['daemon_token']
|
|
72
205
|
},
|
|
73
|
-
description: 'List
|
|
206
|
+
description: 'List browsable tables with row counts',
|
|
74
207
|
query: z.strictObject({ acting: ActingActor }),
|
|
75
208
|
input: z.null(),
|
|
76
209
|
output: z.looseObject({
|
|
77
210
|
tables: z.array(z.strictObject({ name: z.string(), row_count: z.number() }))
|
|
78
211
|
}),
|
|
212
|
+
transaction: true,
|
|
79
213
|
handler: async (c, route) => {
|
|
214
|
+
await set_statement_timeout(route.db);
|
|
215
|
+
// Existence still comes from `information_schema` — a listed table
|
|
216
|
+
// that hasn't migrated in yet is silently absent, not a 500.
|
|
80
217
|
const table_names = await route.db.query(`SELECT table_name FROM information_schema.tables
|
|
81
218
|
WHERE table_schema = 'public'
|
|
82
219
|
ORDER BY table_name`);
|
|
83
220
|
const tables = [];
|
|
84
221
|
for (const { table_name } of table_names) {
|
|
222
|
+
if (!browsable.has(table_name))
|
|
223
|
+
continue;
|
|
85
224
|
const result = await route.db.query_one(`SELECT COUNT(*) as count FROM "${assert_valid_sql_identifier(table_name)}"`);
|
|
86
225
|
tables.push({
|
|
87
226
|
name: table_name,
|
|
@@ -104,13 +243,8 @@ export const create_db_route_specs = (options) => {
|
|
|
104
243
|
params: z.strictObject({ name: z.string().regex(VALID_SQL_IDENTIFIER) }),
|
|
105
244
|
query: z.strictObject({
|
|
106
245
|
acting: ActingActor,
|
|
107
|
-
offset: z.
|
|
108
|
-
limit: z.
|
|
109
|
-
.number()
|
|
110
|
-
.int()
|
|
111
|
-
.min(1)
|
|
112
|
-
.max(DB_TABLE_ROWS_LIMIT_MAX)
|
|
113
|
-
.default(DB_TABLE_ROWS_DEFAULT_LIMIT)
|
|
246
|
+
offset: query_int(z.number().int().min(0), 0),
|
|
247
|
+
limit: query_int(z.number().int().min(1).max(DB_TABLE_ROWS_LIMIT_MAX), DB_TABLE_ROWS_DEFAULT_LIMIT)
|
|
114
248
|
}),
|
|
115
249
|
input: z.null(),
|
|
116
250
|
errors: {
|
|
@@ -127,34 +261,59 @@ export const create_db_route_specs = (options) => {
|
|
|
127
261
|
total: z.number(),
|
|
128
262
|
offset: z.number(),
|
|
129
263
|
limit: z.number(),
|
|
130
|
-
primary_key: z.string().nullable()
|
|
264
|
+
primary_key: z.string().nullable(),
|
|
265
|
+
deletable: z.boolean()
|
|
131
266
|
}),
|
|
267
|
+
transaction: true,
|
|
132
268
|
handler: async (c, route) => {
|
|
133
269
|
const { name } = get_route_params(c);
|
|
134
270
|
const { offset, limit } = get_route_query(c);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (!
|
|
271
|
+
// Allowlist gate, masked: an unlisted (or floor) table answers
|
|
272
|
+
// exactly like one that doesn't exist.
|
|
273
|
+
if (!(await gate_browsable_table(route.db, name))) {
|
|
138
274
|
return c.json({ error: ERROR_TABLE_NOT_FOUND }, 404);
|
|
139
275
|
}
|
|
140
|
-
|
|
276
|
+
// `udt_name` is internal (drives the byte-value placeholder;
|
|
277
|
+
// `data_type` reports only `ARRAY` for array columns) — it is
|
|
278
|
+
// stripped from the response so the wire shape twins Rust's.
|
|
279
|
+
const column_rows = await route.db.query(`SELECT column_name, data_type, is_nullable, udt_name
|
|
141
280
|
FROM information_schema.columns
|
|
142
281
|
WHERE table_schema = 'public' AND table_name = $1
|
|
143
282
|
ORDER BY ordinal_position`, [name]);
|
|
283
|
+
const columns = column_rows.map(({ column_name, data_type, is_nullable }) => ({ column_name, data_type, is_nullable }));
|
|
144
284
|
const count_result = await route.db.query_one(`SELECT COUNT(*) as count FROM "${assert_valid_sql_identifier(name)}"`);
|
|
145
285
|
const total = count_result ? parseInt(count_result.count, 10) : 0;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
286
|
+
// Surface a single-column PK for the delete affordance; a
|
|
287
|
+
// composite (or absent) PK has no single deletable column, so
|
|
288
|
+
// report null — twinning the row-DELETE's single-column rule.
|
|
289
|
+
const pk_columns = await query_primary_key_columns(route.db, name);
|
|
290
|
+
const primary_key = pk_columns.length === 1 ? pk_columns[0] : null;
|
|
291
|
+
// `deletable` is exactly what the row-DELETE will accept, so a
|
|
292
|
+
// client can hide the affordance rather than discover the refusal.
|
|
293
|
+
// `primary_key` stays a truthful report of the key shape — a policy
|
|
294
|
+
// exclusion must not masquerade as one.
|
|
295
|
+
const deletable = primary_key !== null && !non_deletable.has(name);
|
|
296
|
+
// byte values never leave the server — a full page of e.g.
|
|
297
|
+
// `fact.bytes` would otherwise materialize GiBs in one response.
|
|
298
|
+
// The column stays in the result with a `<N bytes>` placeholder
|
|
299
|
+
// per cell (NULL stays NULL); `columns` metadata is untouched.
|
|
300
|
+
// Keyed on `udt_name` so `bytea[]` (`data_type: 'ARRAY'`,
|
|
301
|
+
// `udt_name: '_bytea'`) is covered too — `octet_length` doesn't
|
|
302
|
+
// take arrays, so those report the stored `pg_column_size`.
|
|
303
|
+
const select_list = column_rows.length === 0 // zero-column tables are legal PG
|
|
304
|
+
? '*'
|
|
305
|
+
: column_rows
|
|
306
|
+
.map(({ column_name, udt_name }) => {
|
|
307
|
+
const ident = assert_valid_sql_identifier(column_name);
|
|
308
|
+
if (udt_name === 'bytea')
|
|
309
|
+
return `('<' || octet_length("${ident}") || ' bytes>') AS "${ident}"`;
|
|
310
|
+
if (udt_name === '_bytea')
|
|
311
|
+
return `('<' || pg_column_size("${ident}") || ' bytes>') AS "${ident}"`;
|
|
312
|
+
return `"${ident}"`;
|
|
313
|
+
})
|
|
314
|
+
.join(', ');
|
|
315
|
+
const rows = await route.db.query(`SELECT ${select_list} FROM "${assert_valid_sql_identifier(name)}" LIMIT $1 OFFSET $2`, [limit, offset]);
|
|
316
|
+
return c.json({ columns, rows, total, offset, limit, primary_key, deletable });
|
|
158
317
|
}
|
|
159
318
|
},
|
|
160
319
|
{
|
|
@@ -176,7 +335,11 @@ export const create_db_route_specs = (options) => {
|
|
|
176
335
|
output: z.looseObject({ success: z.boolean() }),
|
|
177
336
|
errors: {
|
|
178
337
|
400: z.looseObject({
|
|
179
|
-
error: z.enum([
|
|
338
|
+
error: z.enum([
|
|
339
|
+
ERROR_INVALID_ROUTE_PARAMS,
|
|
340
|
+
ERROR_TABLE_NO_PRIMARY_KEY,
|
|
341
|
+
ERROR_TABLE_NOT_DELETABLE
|
|
342
|
+
])
|
|
180
343
|
}),
|
|
181
344
|
404: z.looseObject({
|
|
182
345
|
error: z.enum([ERROR_TABLE_NOT_FOUND, ERROR_ROW_NOT_FOUND])
|
|
@@ -185,32 +348,86 @@ export const create_db_route_specs = (options) => {
|
|
|
185
348
|
},
|
|
186
349
|
handler: async (c, route) => {
|
|
187
350
|
const { name, id } = get_route_params(c);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (!
|
|
351
|
+
// Same allowlist gate and masking as the detail route — an
|
|
352
|
+
// unbrowsable table exposes no rows to delete either.
|
|
353
|
+
if (!(await gate_browsable_table(route.db, name))) {
|
|
191
354
|
return c.json({ error: ERROR_TABLE_NOT_FOUND }, 404);
|
|
192
355
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
356
|
+
// Policy exclusion, checked before the key shape: the trail and the
|
|
357
|
+
// framework's singleton bookkeeping rows are never row-deletable through
|
|
358
|
+
// a generic storage endpoint (see `NON_DELETABLE_TABLES`). 400 rather
|
|
359
|
+
// than 403 — this says nothing about the caller's authority, and it sits
|
|
360
|
+
// beside the structural refusal below as the same kind of answer: this
|
|
361
|
+
// table exposes no deletable row here. A 403 would also have to be
|
|
362
|
+
// declared as a union with whatever the route's `auth` derives, which a
|
|
363
|
+
// consumer rewriting `auth` (as fuz_forge does) would silently invalidate.
|
|
364
|
+
if (non_deletable.has(name)) {
|
|
365
|
+
return c.json({ error: ERROR_TABLE_NOT_DELETABLE }, 400);
|
|
366
|
+
}
|
|
367
|
+
// Single-column primary keys only. Deleting by a single
|
|
368
|
+
// `WHERE "<col>" = $1` is safe only when the PK is exactly one
|
|
369
|
+
// column: on a composite PK that filter matches every row sharing
|
|
370
|
+
// the column's value and silently over-deletes (deleting one
|
|
371
|
+
// cell_field row would wipe every field of that name on every
|
|
372
|
+
// cell); an absent PK has nothing to target. Refuse both.
|
|
373
|
+
const pk_columns = await query_primary_key_columns(route.db, name);
|
|
374
|
+
if (pk_columns.length !== 1) {
|
|
203
375
|
return c.json({ error: ERROR_TABLE_NO_PRIMARY_KEY }, 400);
|
|
204
376
|
}
|
|
377
|
+
const pk_column = pk_columns[0];
|
|
205
378
|
try {
|
|
206
|
-
|
|
379
|
+
// Typed PK compare, twinning the Rust spine: `$1` travels as an
|
|
380
|
+
// untyped text-format parameter, so PG infers the PK column's
|
|
381
|
+
// type and coerces the id with that type's input function —
|
|
382
|
+
// index-scan deletes and canonical per-type semantics (citext
|
|
383
|
+
// case-insensitivity, char(n) padding, any valid uuid spelling).
|
|
384
|
+
// An id that cannot be a value of the PK type errors at bind
|
|
385
|
+
// (22P02/22003), mapped below to the same 404 as a typed miss.
|
|
386
|
+
// `RETURNING "<pk>"::text` reads back only the PK cell — the
|
|
387
|
+
// deleted row's other values (e.g. a bytea column) are never
|
|
388
|
+
// materialized — so the audit trail records the row's canonical
|
|
389
|
+
// PK rendering rather than whatever spelling the URL supplied.
|
|
390
|
+
const pk_ident = assert_valid_sql_identifier(pk_column);
|
|
391
|
+
const result = await route.db.query(`DELETE FROM "${assert_valid_sql_identifier(name)}" WHERE "${pk_ident}" = $1 RETURNING "${pk_ident}"::text AS pk_text`, [id]);
|
|
207
392
|
if (result.length === 0) {
|
|
208
393
|
return c.json({ error: ERROR_ROW_NOT_FOUND }, 404);
|
|
209
394
|
}
|
|
395
|
+
const deleted_id = result[0].pk_text;
|
|
396
|
+
// The trail for the trail-adjacent surface: every successful row
|
|
397
|
+
// delete through this endpoint is audited. Deferred via
|
|
398
|
+
// `emit_after_commit` so the pool-routed row can never claim a
|
|
399
|
+
// delete whose transaction failed at COMMIT — the Rust twin
|
|
400
|
+
// commits before emitting for the same reason. Account-grain
|
|
401
|
+
// attribution — the browser is a raw storage surface, so no actor
|
|
402
|
+
// is claimed.
|
|
403
|
+
const account_id = c.var.request_context?.account.id ?? null;
|
|
404
|
+
const ip = get_client_ip(c);
|
|
405
|
+
emit_after_commit(route, () => {
|
|
406
|
+
deps.audit.emit(route, {
|
|
407
|
+
event_type: 'db_admin_row_delete',
|
|
408
|
+
account_id,
|
|
409
|
+
ip,
|
|
410
|
+
metadata: { table: name, pk_column, id: deleted_id }
|
|
411
|
+
});
|
|
412
|
+
});
|
|
210
413
|
return c.json({ success: true });
|
|
211
414
|
}
|
|
212
415
|
catch (err) {
|
|
213
|
-
|
|
416
|
+
const code = pg_error_code(err);
|
|
417
|
+
// 22P02 invalid_text_representation / 22003 numeric_value_out_of_range
|
|
418
|
+
// / 22021 character_not_in_repertoire: the id cannot be coerced
|
|
419
|
+
// to the PK column's type — or cannot even be a text value (a
|
|
420
|
+
// NUL byte, rejected by the server encoding whatever the PK
|
|
421
|
+
// type) — so no row can match; answer with the same masked 404
|
|
422
|
+
// as a typed miss.
|
|
423
|
+
if (code === '22P02' || code === '22003' || code === '22021') {
|
|
424
|
+
return c.json({ error: ERROR_ROW_NOT_FOUND }, 404);
|
|
425
|
+
}
|
|
426
|
+
// 23503 foreign_key_violation / 23001 restrict_violation — the
|
|
427
|
+
// same referencing-row refusal: PG 18+ raises 23001 for `ON
|
|
428
|
+
// DELETE RESTRICT` constraints where PG ≤17 (PGlite today)
|
|
429
|
+
// raises 23503 for both.
|
|
430
|
+
if (code === '23503' || code === '23001') {
|
|
214
431
|
const pg_err = err;
|
|
215
432
|
log?.warn('Foreign key violation:', pg_err.detail, pg_err.constraint);
|
|
216
433
|
return c.json({ error: ERROR_FOREIGN_KEY_VIOLATION }, 409);
|
|
@@ -120,8 +120,19 @@ export declare const ERROR_INVALID_EVENT_TYPE: "invalid_event_type";
|
|
|
120
120
|
export declare const ERROR_FOREIGN_KEY_VIOLATION: "foreign_key_violation";
|
|
121
121
|
/** Table name not found in `information_schema`. */
|
|
122
122
|
export declare const ERROR_TABLE_NOT_FOUND: "table_not_found";
|
|
123
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Table has no single-column primary key, so there is no column the row-`DELETE`
|
|
125
|
+
* can target: either no primary key at all, or a composite one (a single-column
|
|
126
|
+
* `WHERE` on one member of a composite key over-matches).
|
|
127
|
+
*/
|
|
124
128
|
export declare const ERROR_TABLE_NO_PRIMARY_KEY: "table_no_primary_key";
|
|
129
|
+
/**
|
|
130
|
+
* Table is excluded from row deletion by policy — the audit trail and the
|
|
131
|
+
* framework's singleton bookkeeping rows (`bootstrap_lock`, `app_settings`,
|
|
132
|
+
* `schema_version`), whose invariants live in the domain layer rather than in
|
|
133
|
+
* a generic storage endpoint. See `http/db_routes.ts`'s `NON_DELETABLE_TABLES`.
|
|
134
|
+
*/
|
|
135
|
+
export declare const ERROR_TABLE_NOT_DELETABLE: "table_not_deletable";
|
|
125
136
|
/** Row with the given PK value not found. */
|
|
126
137
|
export declare const ERROR_ROW_NOT_FOUND: "row_not_found";
|
|
127
138
|
/** Database health-check query failed (connectivity or query error). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI9D,0CAA0C;AAC1C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE,6CAA6C;AAC7C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAI1E,wCAAwC;AACxC,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAC;AAEhF,+CAA+C;AAC/C,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,yCAAyC;AACzC,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,sFAAsF;AACtF,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,qDAAqD;AACrD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAIpE,uCAAuC;AACvC,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,EAAG,eAAwB,CAAC;AAE5D,0CAA0C;AAC1C,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,EAAG,gBAAyB,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAIlE,uDAAuD;AACvD,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAIlF,qEAAqE;AACrE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,0DAA0D;AAC1D,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAEtE,0GAA0G;AAC1G,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE,gDAAgD;AAChD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,6DAA6D;AAC7D,eAAO,MAAM,oCAAoC,EAAG,gCAAyC,CAAC;AAE9F,0DAA0D;AAC1D,eAAO,MAAM,iCAAiC,EAAG,6BAAsC,CAAC;AAIxF,6DAA6D;AAC7D,eAAO,MAAM,4BAA4B,EAAG,wBAAiC,CAAC;AAE9E,gEAAgE;AAChE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,oEAAoE;AACpE,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,kDAAkD;AAClD,eAAO,MAAM,2BAA2B,EAAG,uBAAgC,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE
|
|
1
|
+
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"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"}
|
|
@@ -128,8 +128,19 @@ export const ERROR_INVALID_EVENT_TYPE = 'invalid_event_type';
|
|
|
128
128
|
export const ERROR_FOREIGN_KEY_VIOLATION = 'foreign_key_violation';
|
|
129
129
|
/** Table name not found in `information_schema`. */
|
|
130
130
|
export const ERROR_TABLE_NOT_FOUND = 'table_not_found';
|
|
131
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Table has no single-column primary key, so there is no column the row-`DELETE`
|
|
133
|
+
* can target: either no primary key at all, or a composite one (a single-column
|
|
134
|
+
* `WHERE` on one member of a composite key over-matches).
|
|
135
|
+
*/
|
|
132
136
|
export const ERROR_TABLE_NO_PRIMARY_KEY = 'table_no_primary_key';
|
|
137
|
+
/**
|
|
138
|
+
* Table is excluded from row deletion by policy — the audit trail and the
|
|
139
|
+
* framework's singleton bookkeeping rows (`bootstrap_lock`, `app_settings`,
|
|
140
|
+
* `schema_version`), whose invariants live in the domain layer rather than in
|
|
141
|
+
* a generic storage endpoint. See `http/db_routes.ts`'s `NON_DELETABLE_TABLES`.
|
|
142
|
+
*/
|
|
143
|
+
export const ERROR_TABLE_NOT_DELETABLE = 'table_not_deletable';
|
|
133
144
|
/** Row with the given PK value not found. */
|
|
134
145
|
export const ERROR_ROW_NOT_FOUND = 'row_not_found';
|
|
135
146
|
/** Database health-check query failed (connectivity or query error). */
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
43
43
|
/**
|
|
44
|
-
* Minimal structural context required by `emit_after_commit
|
|
45
|
-
* `RouteContext` and `ActionContext` satisfy this
|
|
46
|
-
* `log`
|
|
44
|
+
* Minimal structural context required by `emit_after_commit` — just the
|
|
45
|
+
* deferred queue. Both `RouteContext` and `ActionContext` satisfy this.
|
|
46
|
+
* (No `log` here: the flush middleware owns the per-thunk `try/catch` +
|
|
47
|
+
* `log.error`, with a logger of its own.)
|
|
47
48
|
*/
|
|
48
49
|
export interface EmitAfterCommitContext {
|
|
49
|
-
log: Logger;
|
|
50
50
|
post_commit_effects: Array<() => void | Promise<void>>;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
@@ -71,7 +71,7 @@ export interface EmitAfterCommitContext {
|
|
|
71
71
|
* land even when the handler fails) belong on the eager `pending_effects` queue
|
|
72
72
|
* instead.
|
|
73
73
|
*
|
|
74
|
-
* @param ctx - context carrying
|
|
74
|
+
* @param ctx - context carrying the `post_commit_effects` queue
|
|
75
75
|
* @param fn - side effect to run after commit; may return `void` or `Promise<void>`
|
|
76
76
|
* @mutates `ctx.post_commit_effects` - appends `fn` verbatim
|
|
77
77
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD
|
|
1
|
+
{"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACtC,mBAAmB,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,iBAAiB,GAC7B,KAAK,sBAAsB,EAC3B,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAC5B,IAEF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kCAAkC,GAAU,CAAC,EACzD,qBAAqB,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EAClE,UAAU,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,KAC5B,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAQpB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,EACrC,KAAK,MAAM,EACX,eAAe,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,KACtC,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,aAAa,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAClD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAiBd,CAAC"}
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
* land even when the handler fails) belong on the eager `pending_effects` queue
|
|
62
62
|
* instead.
|
|
63
63
|
*
|
|
64
|
-
* @param ctx - context carrying
|
|
64
|
+
* @param ctx - context carrying the `post_commit_effects` queue
|
|
65
65
|
* @param fn - side effect to run after commit; may return `void` or `Promise<void>`
|
|
66
66
|
* @mutates `ctx.post_commit_effects` - appends `fn` verbatim
|
|
67
67
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deno.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deno.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,WAAW,EAA6B,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"deno.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deno.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,WAAW,EAA6B,MAAM,WAAW,CAAC;AAoExE;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,KAAG,WAuKhE,CAAC"}
|
package/dist/runtime/deno.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
import { to_error_message } from '@fuzdev/fuz_util/error.ts';
|
|
11
|
+
import { assert_secure_mode, assert_secure_size, read_secure_bounded } from './secure_file.js';
|
|
11
12
|
/**
|
|
12
13
|
* Create a runtime backed by Deno APIs.
|
|
13
14
|
*
|
|
@@ -44,6 +45,28 @@ export const create_deno_runtime = (args) => ({
|
|
|
44
45
|
mkdir: (path, options) => Deno.mkdir(path, options),
|
|
45
46
|
read_text_file: (path) => Deno.readTextFile(path),
|
|
46
47
|
read_file: (path) => Deno.readFile(path),
|
|
48
|
+
read_secure_file: async (path) => {
|
|
49
|
+
// Deno exposes no O_NOFOLLOW, so the symlink check is a pre-open lstat
|
|
50
|
+
// (a TOCTOU window the Node impl doesn't have — the mode check below
|
|
51
|
+
// still runs on the open descriptor, so a post-open swap can't defeat
|
|
52
|
+
// it). The checks themselves are the shared `runtime/secure_file.ts`
|
|
53
|
+
// helpers, same as `load_secure_file_node`.
|
|
54
|
+
const l = await Deno.lstat(path);
|
|
55
|
+
if (l.isSymlink)
|
|
56
|
+
throw new Error(`secure file is a symlink: ${path}`);
|
|
57
|
+
const file = await Deno.open(path, { read: true });
|
|
58
|
+
try {
|
|
59
|
+
const s = await file.stat();
|
|
60
|
+
// `mode` is null where POSIX modes don't apply (Windows).
|
|
61
|
+
if (s.mode !== null)
|
|
62
|
+
assert_secure_mode(path, s.mode);
|
|
63
|
+
assert_secure_size(path, s.size);
|
|
64
|
+
return await read_secure_bounded(path, (target) => file.read(target));
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
file.close();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
47
70
|
read_file_stream: async (path) => (await Deno.open(path, { read: true })).readable,
|
|
48
71
|
write_file_stream: async (path, data) => {
|
|
49
72
|
const file = await Deno.open(path, { write: true, create: true, truncate: true });
|
|
@@ -78,7 +101,10 @@ export const create_deno_runtime = (args) => ({
|
|
|
78
101
|
names.push(entry.name);
|
|
79
102
|
return names;
|
|
80
103
|
},
|
|
81
|
-
write_text_file: (path, content) => Deno.writeTextFile(path, content
|
|
104
|
+
write_text_file: (path, content, options) => Deno.writeTextFile(path, content, {
|
|
105
|
+
mode: options?.mode,
|
|
106
|
+
createNew: options?.exclusive
|
|
107
|
+
}),
|
|
82
108
|
write_file: (path, data) => Deno.writeFile(path, data),
|
|
83
109
|
rename: (old_path, new_path) => Deno.rename(old_path, new_path),
|
|
84
110
|
fsync: async (path) => {
|
package/dist/runtime/deps.d.ts
CHANGED
|
@@ -79,6 +79,39 @@ export interface ReadTextFromOffsetResult {
|
|
|
79
79
|
/** Total file size at the time of the read (for truncation detection). */
|
|
80
80
|
file_size: number;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Options for `FsWriteDeps.write_text_file`.
|
|
84
|
+
*/
|
|
85
|
+
export interface WriteFileOptions {
|
|
86
|
+
/**
|
|
87
|
+
* POSIX mode applied at creation (e.g. `0o600`). Creation-time only — an
|
|
88
|
+
* existing file keeps its mode (pair with `exclusive` when the mode must
|
|
89
|
+
* hold). No-op where modes don't apply (mock runtime, Windows).
|
|
90
|
+
*/
|
|
91
|
+
mode?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Fail if the path already exists (`O_EXCL`). Load-bearing for secret
|
|
94
|
+
* files: without it a crash-leftover temp file is reopened `O_TRUNC` and
|
|
95
|
+
* keeps its old (possibly permissive) mode, since `mode` only applies at
|
|
96
|
+
* creation.
|
|
97
|
+
*/
|
|
98
|
+
exclusive?: boolean;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Hardened read for secret files (bootstrap tokens, keys) — the TS twin of
|
|
102
|
+
* the Rust spine's `fuz_sys::secure_file::load_secure_file`.
|
|
103
|
+
*/
|
|
104
|
+
export interface FsSecureReadDeps {
|
|
105
|
+
/**
|
|
106
|
+
* Read a small secret file with fail-loud checks: rejects symlinks, any
|
|
107
|
+
* group/other-accessible mode (must be `0600`/`0400`; skipped where POSIX
|
|
108
|
+
* modes don't apply), and files over a 4 KiB cap. Permission checks run on
|
|
109
|
+
* the open descriptor where the platform allows, so the file can't be
|
|
110
|
+
* swapped between check and read. Throws on any refusal — a refused secret
|
|
111
|
+
* must never degrade to an empty read.
|
|
112
|
+
*/
|
|
113
|
+
read_secure_file: (path: string) => Promise<Uint8Array>;
|
|
114
|
+
}
|
|
82
115
|
/**
|
|
83
116
|
* File system read operations.
|
|
84
117
|
*/
|
|
@@ -104,12 +137,13 @@ export interface FsReadDeps {
|
|
|
104
137
|
* File system write operations.
|
|
105
138
|
*/
|
|
106
139
|
export interface FsWriteDeps {
|
|
107
|
-
/** Create a directory. */
|
|
140
|
+
/** Create a directory. `mode` applies at creation (no-op where modes don't apply). */
|
|
108
141
|
mkdir: (path: string, options?: {
|
|
109
142
|
recursive?: boolean;
|
|
143
|
+
mode?: number;
|
|
110
144
|
}) => Promise<void>;
|
|
111
|
-
/** Write text to a file. */
|
|
112
|
-
write_text_file: (path: string, content: string) => Promise<void>;
|
|
145
|
+
/** Write text to a file. See `WriteFileOptions` for `mode` / `exclusive`. */
|
|
146
|
+
write_text_file: (path: string, content: string, options?: WriteFileOptions) => Promise<void>;
|
|
113
147
|
/** Write bytes to a file. */
|
|
114
148
|
write_file: (path: string, data: Uint8Array) => Promise<void>;
|
|
115
149
|
/** Rename (move) a file. */
|
|
@@ -213,7 +247,7 @@ export interface ProcessDeps {
|
|
|
213
247
|
* Functions should accept narrow `*Deps` interfaces, not this full type —
|
|
214
248
|
* this type is for the wiring layer that creates and passes the runtime.
|
|
215
249
|
*/
|
|
216
|
-
export interface RuntimeDeps extends EnvDeps, FsReadDeps, FsWriteDeps, FsStreamDeps, FsRemoveDeps, CommandDeps, FetchDeps, TerminalDeps, ProcessDeps, LogDeps {
|
|
250
|
+
export interface RuntimeDeps extends EnvDeps, FsReadDeps, FsSecureReadDeps, FsWriteDeps, FsStreamDeps, FsRemoveDeps, CommandDeps, FetchDeps, TerminalDeps, ProcessDeps, LogDeps {
|
|
217
251
|
/** Get all environment variables. */
|
|
218
252
|
env_all: () => Record<string, string>;
|
|
219
253
|
/** CLI arguments passed to the program. */
|