@trokky/trokky 3.1.0 → 3.3.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/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.d.ts +140 -0
- package/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.d.ts.map +1 -0
- package/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.js +853 -0
- package/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.js.map +1 -0
- package/dist/adapters/cloudflare-d1/index.d.ts +5 -0
- package/dist/adapters/cloudflare-d1/index.d.ts.map +1 -0
- package/dist/adapters/cloudflare-d1/index.js +12 -0
- package/dist/adapters/cloudflare-d1/index.js.map +1 -0
- package/dist/adapters/cloudflare-d1/rate-limit-store.d.ts +46 -0
- package/dist/adapters/cloudflare-d1/rate-limit-store.d.ts.map +1 -0
- package/dist/adapters/cloudflare-d1/rate-limit-store.js +96 -0
- package/dist/adapters/cloudflare-d1/rate-limit-store.js.map +1 -0
- package/dist/adapters/cloudflare-d1/types.d.ts +69 -0
- package/dist/adapters/cloudflare-d1/types.d.ts.map +1 -0
- package/dist/adapters/cloudflare-d1/types.js +2 -0
- package/dist/adapters/cloudflare-d1/types.js.map +1 -0
- package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.d.ts +86 -0
- package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.d.ts.map +1 -0
- package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.js +493 -0
- package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.js.map +1 -0
- package/dist/adapters/cloudflare-r2/index.d.ts +3 -0
- package/dist/adapters/cloudflare-r2/index.d.ts.map +1 -0
- package/dist/adapters/cloudflare-r2/index.js +11 -0
- package/dist/adapters/cloudflare-r2/index.js.map +1 -0
- package/dist/adapters/cloudflare-r2/types.d.ts +43 -0
- package/dist/adapters/cloudflare-r2/types.d.ts.map +1 -0
- package/dist/adapters/cloudflare-r2/types.js +2 -0
- package/dist/adapters/cloudflare-r2/types.js.map +1 -0
- package/dist/adapters/filesystem-data/filesystem-data-adapter.d.ts +9 -0
- package/dist/adapters/filesystem-data/filesystem-data-adapter.d.ts.map +1 -1
- package/dist/adapters/filesystem-data/filesystem-data-adapter.js +28 -0
- package/dist/adapters/filesystem-data/filesystem-data-adapter.js.map +1 -1
- package/dist/adapters/postgres-data/postgres-data-adapter.d.ts +9 -0
- package/dist/adapters/postgres-data/postgres-data-adapter.d.ts.map +1 -1
- package/dist/adapters/postgres-data/postgres-data-adapter.js +59 -0
- package/dist/adapters/postgres-data/postgres-data-adapter.js.map +1 -1
- package/dist/core/core/engine.d.ts +10 -0
- package/dist/core/core/engine.d.ts.map +1 -1
- package/dist/core/core/engine.js +16 -2
- package/dist/core/core/engine.js.map +1 -1
- package/dist/core/media/image-processor.d.ts.map +1 -1
- package/dist/core/media/image-processor.js +5 -13
- package/dist/core/media/image-processor.js.map +1 -1
- package/dist/core/media/processors/sharp.d.ts.map +1 -1
- package/dist/core/media/processors/sharp.js +13 -2
- package/dist/core/media/processors/sharp.js.map +1 -1
- package/dist/core/media/processors/types.d.ts +1 -1
- package/dist/core/media/processors/types.d.ts.map +1 -1
- package/dist/core/security/rate-limit-store.d.ts +56 -0
- package/dist/core/security/rate-limit-store.d.ts.map +1 -0
- package/dist/core/security/rate-limit-store.js +55 -0
- package/dist/core/security/rate-limit-store.js.map +1 -0
- package/dist/core/security/rate-limiter.d.ts +39 -2
- package/dist/core/security/rate-limiter.d.ts.map +1 -1
- package/dist/core/security/rate-limiter.js +52 -20
- package/dist/core/security/rate-limiter.js.map +1 -1
- package/dist/core/services/media-service.d.ts +34 -0
- package/dist/core/services/media-service.d.ts.map +1 -1
- package/dist/core/services/media-service.js +172 -120
- package/dist/core/services/media-service.js.map +1 -1
- package/dist/core/services/user-service.d.ts +42 -0
- package/dist/core/services/user-service.d.ts.map +1 -1
- package/dist/core/services/user-service.js +114 -1
- package/dist/core/services/user-service.js.map +1 -1
- package/dist/core/types/storage-adapters.d.ts +18 -0
- package/dist/core/types/storage-adapters.d.ts.map +1 -1
- package/dist/core/utils/retry.d.ts +45 -0
- package/dist/core/utils/retry.d.ts.map +1 -0
- package/dist/core/utils/retry.js +47 -0
- package/dist/core/utils/retry.js.map +1 -0
- package/dist/i18n/config.d.ts +32 -0
- package/dist/i18n/config.d.ts.map +1 -1
- package/dist/i18n/locales/en/auth.json +16 -0
- package/dist/i18n/locales/fr/auth.json +16 -0
- package/dist/integrations/workers/adapter.d.ts +28 -0
- package/dist/integrations/workers/adapter.d.ts.map +1 -0
- package/dist/integrations/workers/adapter.js +111 -0
- package/dist/integrations/workers/adapter.js.map +1 -0
- package/dist/integrations/workers/index.d.ts +22 -0
- package/dist/integrations/workers/index.d.ts.map +1 -0
- package/dist/integrations/workers/index.js +60 -0
- package/dist/integrations/workers/index.js.map +1 -0
- package/dist/routes/auth/auth-flow-store.d.ts +17 -0
- package/dist/routes/auth/auth-flow-store.d.ts.map +1 -1
- package/dist/routes/auth/auth-flow-store.js +27 -0
- package/dist/routes/auth/auth-flow-store.js.map +1 -1
- package/dist/routes/auth/oauth.d.ts.map +1 -1
- package/dist/routes/auth/oauth.js +5 -12
- package/dist/routes/auth/oauth.js.map +1 -1
- package/dist/routes/auth/passkey.d.ts.map +1 -1
- package/dist/routes/auth/passkey.js +5 -8
- package/dist/routes/auth/passkey.js.map +1 -1
- package/dist/routes/handlers/auth.d.ts +14 -0
- package/dist/routes/handlers/auth.d.ts.map +1 -1
- package/dist/routes/handlers/auth.js +55 -0
- package/dist/routes/handlers/auth.js.map +1 -1
- package/dist/structure/validation/schemas.d.ts +60 -60
- package/package.json +20 -1
- package/dist/core/media/processors/cloudflare-images.d.ts +0 -83
- package/dist/core/media/processors/cloudflare-images.d.ts.map +0 -1
- package/dist/core/media/processors/cloudflare-images.js +0 -241
- package/dist/core/media/processors/cloudflare-images.js.map +0 -1
- package/dist/core/media/processors/cloudflare-transform-store.d.ts +0 -127
- package/dist/core/media/processors/cloudflare-transform-store.d.ts.map +0 -1
- package/dist/core/media/processors/cloudflare-transform-store.js +0 -237
- package/dist/core/media/processors/cloudflare-transform-store.js.map +0 -1
- package/dist/core/media/processors/cloudflare-transformations.d.ts +0 -81
- package/dist/core/media/processors/cloudflare-transformations.d.ts.map +0 -1
- package/dist/core/media/processors/cloudflare-transformations.js +0 -144
- package/dist/core/media/processors/cloudflare-transformations.js.map +0 -1
- package/dist/core/media/processors/workers-images.d.ts +0 -112
- package/dist/core/media/processors/workers-images.d.ts.map +0 -1
- package/dist/core/media/processors/workers-images.js +0 -257
- package/dist/core/media/processors/workers-images.js.map +0 -1
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare D1 data storage adapter.
|
|
3
|
+
*
|
|
4
|
+
* D1 is SQLite, which differs from Postgres in ways that are invisible until a
|
|
5
|
+
* conformance test fails, so the choices below are deliberate:
|
|
6
|
+
*
|
|
7
|
+
* - **Timestamps are generated in JS, never by SQL.** SQLite's `CURRENT_TIMESTAMP`
|
|
8
|
+
* has one-second resolution; the suite orders fixtures 20ms apart, so rows would
|
|
9
|
+
* tie and sort arbitrarily.
|
|
10
|
+
* - **Document payloads are one JSON TEXT blob.** Shredding fields into columns
|
|
11
|
+
* loses the distinction between `0`, `false`, `''`, `null` and absent, all of
|
|
12
|
+
* which must round-trip byte-identically.
|
|
13
|
+
* - **Filters compare value *and* `json_type`.** `json_extract` collapses JSON
|
|
14
|
+
* booleans to 0/1, so a naive equality makes `false` match a stored `0`.
|
|
15
|
+
* - **Sorts use `json_extract`**, which yields real SQL numbers; the text
|
|
16
|
+
* accessors would sort 13 before 2.
|
|
17
|
+
* - **There is no `beginTransaction`.** D1 has no interactive transactions, and
|
|
18
|
+
* the interface allows omitting it. The three places that need atomicity each
|
|
19
|
+
* do it in one statement: the document upsert, `saveUserIf`'s conditional
|
|
20
|
+
* UPDATE, and `consumeAuthFlowState`'s `DELETE … RETURNING`.
|
|
21
|
+
*/
|
|
22
|
+
import { InvalidInputError, SecurityValidator, createLogger } from '../../core/index.js';
|
|
23
|
+
/** Field names allowed in a sort or filter position. */
|
|
24
|
+
const FIELD_PATTERN = /^[a-zA-Z0-9_.\-]+$/;
|
|
25
|
+
/**
|
|
26
|
+
* Document fields that live in their own column. Anything else — including any
|
|
27
|
+
* other leading-underscore field a schema defines — is addressed through `data`.
|
|
28
|
+
*/
|
|
29
|
+
const DOCUMENT_SYSTEM_COLUMNS = {
|
|
30
|
+
id: 'id',
|
|
31
|
+
_id: 'id',
|
|
32
|
+
_status: 'status',
|
|
33
|
+
_revision: 'revision',
|
|
34
|
+
_createdAt: 'created_at',
|
|
35
|
+
_updatedAt: 'updated_at',
|
|
36
|
+
_createdBy: 'created_by',
|
|
37
|
+
_updatedBy: 'updated_by',
|
|
38
|
+
_createdByType: 'created_by_type',
|
|
39
|
+
_updatedByType: 'updated_by_type'
|
|
40
|
+
};
|
|
41
|
+
/** The user INSERT column list, in the order `userInsertValues` binds them. */
|
|
42
|
+
const USER_INSERT_COLUMNS = `(id, username, email, password_hash, first_name, last_name, role, permissions,
|
|
43
|
+
is_active, profile_image, preferences, oauth_providers, mfa, passkeys,
|
|
44
|
+
last_login_at, created_at, updated_at)`;
|
|
45
|
+
/** User columns an update may set, in write order. */
|
|
46
|
+
const USER_UPDATE_COLUMNS = [
|
|
47
|
+
{ key: 'username', column: 'username' },
|
|
48
|
+
{ key: 'email', column: 'email' },
|
|
49
|
+
{ key: 'passwordHash', column: 'password_hash' },
|
|
50
|
+
{ key: 'firstName', column: 'first_name' },
|
|
51
|
+
{ key: 'lastName', column: 'last_name' },
|
|
52
|
+
{ key: 'role', column: 'role' },
|
|
53
|
+
{ key: 'permissions', column: 'permissions', json: true },
|
|
54
|
+
{ key: 'isActive', column: 'is_active', bool: true },
|
|
55
|
+
{ key: 'profileImage', column: 'profile_image' },
|
|
56
|
+
{ key: 'preferences', column: 'preferences', json: true },
|
|
57
|
+
{ key: 'oauthProviders', column: 'oauth_providers', json: true },
|
|
58
|
+
{ key: 'mfa', column: 'mfa', json: true },
|
|
59
|
+
{ key: 'passkeys', column: 'passkeys', json: true },
|
|
60
|
+
{ key: 'lastLoginAt', column: 'last_login_at' }
|
|
61
|
+
];
|
|
62
|
+
/** A SQL NULL must surface as `undefined`; `null` is reserved for real JSON nulls. */
|
|
63
|
+
function orUndefined(value) {
|
|
64
|
+
return value === null || value === undefined ? undefined : value;
|
|
65
|
+
}
|
|
66
|
+
/** JSON columns hold NULL when absent, never '{}' or '[]'. */
|
|
67
|
+
function jsonColumn(value) {
|
|
68
|
+
return value === null || value === undefined ? null : JSON.stringify(value);
|
|
69
|
+
}
|
|
70
|
+
function parseJsonColumn(value) {
|
|
71
|
+
if (value === null || value === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(value);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** SQLite has no boolean; 0/1 round-trips through INTEGER. */
|
|
81
|
+
function boolColumn(value) {
|
|
82
|
+
return value === null || value === undefined ? null : value ? 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
/** The `json_type` a stored value must have to equal this JS value. */
|
|
85
|
+
function expectedJsonType(value) {
|
|
86
|
+
if (value === null)
|
|
87
|
+
return 'null';
|
|
88
|
+
if (typeof value === 'boolean')
|
|
89
|
+
return value ? 'true' : 'false';
|
|
90
|
+
if (typeof value === 'number')
|
|
91
|
+
return Number.isInteger(value) ? 'integer' : 'real';
|
|
92
|
+
if (typeof value === 'string')
|
|
93
|
+
return 'text';
|
|
94
|
+
if (Array.isArray(value))
|
|
95
|
+
return 'array';
|
|
96
|
+
return 'object';
|
|
97
|
+
}
|
|
98
|
+
/** A JSON path that survives field names containing dots or hyphens. */
|
|
99
|
+
function jsonPath(field) {
|
|
100
|
+
return `$."${field.replace(/"/g, '""')}"`;
|
|
101
|
+
}
|
|
102
|
+
function assertField(field) {
|
|
103
|
+
if (!FIELD_PATTERN.test(field)) {
|
|
104
|
+
throw new InvalidInputError(`Invalid field name: ${field}`, 'field');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export class CloudflareD1Adapter {
|
|
108
|
+
db;
|
|
109
|
+
tablePrefix;
|
|
110
|
+
autoMigrate;
|
|
111
|
+
logger = createLogger('adapter', 'CloudflareD1');
|
|
112
|
+
migrated = false;
|
|
113
|
+
migrationPromise = null;
|
|
114
|
+
/** D1 rejects a single value over ~2MB, well below the 10MB other adapters allow. */
|
|
115
|
+
MAX_DOCUMENT_SIZE = 1_500_000;
|
|
116
|
+
MAX_LIST_LIMIT = 1000;
|
|
117
|
+
constructor(config) {
|
|
118
|
+
if (!config?.database) {
|
|
119
|
+
throw new InvalidInputError('A D1 database binding is required', 'database');
|
|
120
|
+
}
|
|
121
|
+
this.db = config.database;
|
|
122
|
+
this.tablePrefix = config.tablePrefix ?? 'trokky_';
|
|
123
|
+
this.autoMigrate = config.autoMigrate ?? true;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------- plumbing
|
|
126
|
+
table(name) {
|
|
127
|
+
return `${this.tablePrefix}${name}`;
|
|
128
|
+
}
|
|
129
|
+
now() {
|
|
130
|
+
return new Date().toISOString();
|
|
131
|
+
}
|
|
132
|
+
/** Migrations run once; concurrent callers await the same promise. */
|
|
133
|
+
async ready() {
|
|
134
|
+
if (this.migrated)
|
|
135
|
+
return;
|
|
136
|
+
if (!this.autoMigrate) {
|
|
137
|
+
this.migrated = true;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!this.migrationPromise) {
|
|
141
|
+
this.migrationPromise = this.migrate().then(() => {
|
|
142
|
+
this.migrated = true;
|
|
143
|
+
}, error => {
|
|
144
|
+
this.migrationPromise = null;
|
|
145
|
+
throw error;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
await this.migrationPromise;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Create the schema. No DEFAULTs on the JSON columns: a default of '{}' would
|
|
152
|
+
* turn an absent value into an empty object on read, where `undefined` is
|
|
153
|
+
* required.
|
|
154
|
+
*/
|
|
155
|
+
async migrate() {
|
|
156
|
+
const statements = [
|
|
157
|
+
`CREATE TABLE IF NOT EXISTS ${this.table('documents')} (
|
|
158
|
+
collection TEXT NOT NULL,
|
|
159
|
+
id TEXT NOT NULL,
|
|
160
|
+
data TEXT NOT NULL,
|
|
161
|
+
status TEXT,
|
|
162
|
+
created_at TEXT NOT NULL,
|
|
163
|
+
updated_at TEXT NOT NULL,
|
|
164
|
+
created_by TEXT,
|
|
165
|
+
updated_by TEXT,
|
|
166
|
+
created_by_type TEXT,
|
|
167
|
+
updated_by_type TEXT,
|
|
168
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
169
|
+
PRIMARY KEY (collection, id)
|
|
170
|
+
)`,
|
|
171
|
+
`CREATE INDEX IF NOT EXISTS ${this.table('documents')}_collection_created
|
|
172
|
+
ON ${this.table('documents')} (collection, created_at DESC)`,
|
|
173
|
+
`CREATE TABLE IF NOT EXISTS ${this.table('users')} (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
username TEXT NOT NULL UNIQUE,
|
|
176
|
+
email TEXT NOT NULL UNIQUE,
|
|
177
|
+
password_hash TEXT,
|
|
178
|
+
first_name TEXT,
|
|
179
|
+
last_name TEXT,
|
|
180
|
+
role TEXT,
|
|
181
|
+
permissions TEXT,
|
|
182
|
+
is_active INTEGER,
|
|
183
|
+
profile_image TEXT,
|
|
184
|
+
preferences TEXT,
|
|
185
|
+
oauth_providers TEXT,
|
|
186
|
+
mfa TEXT,
|
|
187
|
+
passkeys TEXT,
|
|
188
|
+
last_login_at TEXT,
|
|
189
|
+
created_at TEXT NOT NULL,
|
|
190
|
+
updated_at TEXT NOT NULL
|
|
191
|
+
)`,
|
|
192
|
+
`CREATE INDEX IF NOT EXISTS ${this.table('users')}_created ON ${this.table('users')} (created_at DESC)`,
|
|
193
|
+
`CREATE TABLE IF NOT EXISTS ${this.table('app_tokens')} (
|
|
194
|
+
id TEXT PRIMARY KEY,
|
|
195
|
+
name TEXT NOT NULL,
|
|
196
|
+
description TEXT,
|
|
197
|
+
hash TEXT NOT NULL UNIQUE,
|
|
198
|
+
permissions TEXT,
|
|
199
|
+
created_by TEXT,
|
|
200
|
+
is_active INTEGER,
|
|
201
|
+
usage_count INTEGER DEFAULT 0,
|
|
202
|
+
last_used_at TEXT,
|
|
203
|
+
expires_at TEXT,
|
|
204
|
+
created_at TEXT NOT NULL,
|
|
205
|
+
updated_at TEXT NOT NULL
|
|
206
|
+
)`,
|
|
207
|
+
`CREATE TABLE IF NOT EXISTS ${this.table('auth_flow_state')} (
|
|
208
|
+
id TEXT PRIMARY KEY,
|
|
209
|
+
kind TEXT NOT NULL,
|
|
210
|
+
data TEXT NOT NULL,
|
|
211
|
+
expires_at TEXT NOT NULL,
|
|
212
|
+
created_at TEXT NOT NULL
|
|
213
|
+
)`,
|
|
214
|
+
`CREATE INDEX IF NOT EXISTS ${this.table('auth_flow_state')}_expires
|
|
215
|
+
ON ${this.table('auth_flow_state')} (expires_at)`,
|
|
216
|
+
`CREATE TABLE IF NOT EXISTS ${this.table('settings')} (
|
|
217
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
218
|
+
data TEXT NOT NULL,
|
|
219
|
+
updated_at TEXT NOT NULL
|
|
220
|
+
)`
|
|
221
|
+
];
|
|
222
|
+
for (const sql of statements) {
|
|
223
|
+
await this.db.prepare(sql).run();
|
|
224
|
+
}
|
|
225
|
+
this.logger.info('D1 schema ready');
|
|
226
|
+
}
|
|
227
|
+
async close() {
|
|
228
|
+
// The binding is owned by the platform; there is nothing to release.
|
|
229
|
+
}
|
|
230
|
+
async healthCheck() {
|
|
231
|
+
try {
|
|
232
|
+
await this.ready();
|
|
233
|
+
const row = await this.db.prepare('SELECT 1 AS ok').first();
|
|
234
|
+
return row?.ok === 1;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
this.logger.warn('D1 health check failed', error);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// --------------------------------------------------------------- documents
|
|
242
|
+
mapRowToDocument(row) {
|
|
243
|
+
const data = parseJsonColumn(row.data) ?? {};
|
|
244
|
+
// Any `_status` that leaked into the payload must not shadow the column.
|
|
245
|
+
delete data._status;
|
|
246
|
+
return {
|
|
247
|
+
id: row.id,
|
|
248
|
+
_id: row.id,
|
|
249
|
+
_collection: row.collection,
|
|
250
|
+
_createdAt: new Date(row.created_at),
|
|
251
|
+
_updatedAt: new Date(row.updated_at),
|
|
252
|
+
_createdBy: orUndefined(row.created_by),
|
|
253
|
+
_updatedBy: orUndefined(row.updated_by),
|
|
254
|
+
_createdByType: orUndefined(row.created_by_type),
|
|
255
|
+
_updatedByType: orUndefined(row.updated_by_type),
|
|
256
|
+
_revision: Number(row.revision ?? 1),
|
|
257
|
+
_status: (orUndefined(row.status) ?? 'draft'),
|
|
258
|
+
...data
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
async getDocument(collection, id) {
|
|
262
|
+
SecurityValidator.validateCollectionName(collection);
|
|
263
|
+
SecurityValidator.validateDocumentId(id);
|
|
264
|
+
await this.ready();
|
|
265
|
+
const row = await this.db
|
|
266
|
+
.prepare(`SELECT * FROM ${this.table('documents')} WHERE collection = ? AND id = ?`)
|
|
267
|
+
.bind(collection, id)
|
|
268
|
+
.first();
|
|
269
|
+
return row ? this.mapRowToDocument(row) : null;
|
|
270
|
+
}
|
|
271
|
+
async saveDocument(collection, id, data, auditContext) {
|
|
272
|
+
SecurityValidator.validateCollectionName(collection);
|
|
273
|
+
SecurityValidator.validateDocumentId(id);
|
|
274
|
+
SecurityValidator.validateDocumentData(data);
|
|
275
|
+
await this.ready();
|
|
276
|
+
const { _status, ...payload } = (data ?? {});
|
|
277
|
+
const serialized = JSON.stringify(payload);
|
|
278
|
+
if (serialized.length > this.MAX_DOCUMENT_SIZE) {
|
|
279
|
+
throw new InvalidInputError(`Document exceeds the ${this.MAX_DOCUMENT_SIZE} byte limit for D1`, 'data');
|
|
280
|
+
}
|
|
281
|
+
const now = this.now();
|
|
282
|
+
const status = _status ?? null;
|
|
283
|
+
const actor = auditContext?.userId ?? null;
|
|
284
|
+
const actorType = auditContext?.userType ?? null;
|
|
285
|
+
const documents = this.table('documents');
|
|
286
|
+
// created_by / created_by_type are absent from the UPDATE list on purpose:
|
|
287
|
+
// that is what preserves the original creator across later saves.
|
|
288
|
+
const row = await this.db
|
|
289
|
+
.prepare(`INSERT INTO ${documents}
|
|
290
|
+
(collection, id, data, status, created_at, updated_at,
|
|
291
|
+
created_by, updated_by, created_by_type, updated_by_type, revision)
|
|
292
|
+
VALUES (?, ?, ?, COALESCE(?, 'draft'), ?, ?, ?, ?, ?, ?, 1)
|
|
293
|
+
ON CONFLICT(collection, id) DO UPDATE SET
|
|
294
|
+
data = excluded.data,
|
|
295
|
+
status = COALESCE(?, ${documents}.status, 'draft'),
|
|
296
|
+
updated_at = excluded.updated_at,
|
|
297
|
+
updated_by = excluded.updated_by,
|
|
298
|
+
updated_by_type = excluded.updated_by_type,
|
|
299
|
+
revision = COALESCE(${documents}.revision, 1) + 1
|
|
300
|
+
RETURNING *`)
|
|
301
|
+
.bind(collection, id, serialized, status, now, now, actor, actor, actorType, actorType, status)
|
|
302
|
+
.first();
|
|
303
|
+
if (!row) {
|
|
304
|
+
throw new Error(`Failed to save document ${collection}/${id}`);
|
|
305
|
+
}
|
|
306
|
+
return this.mapRowToDocument(row);
|
|
307
|
+
}
|
|
308
|
+
async deleteDocument(collection, id) {
|
|
309
|
+
SecurityValidator.validateCollectionName(collection);
|
|
310
|
+
SecurityValidator.validateDocumentId(id);
|
|
311
|
+
await this.ready();
|
|
312
|
+
const result = await this.db
|
|
313
|
+
.prepare(`DELETE FROM ${this.table('documents')} WHERE collection = ? AND id = ?`)
|
|
314
|
+
.bind(collection, id)
|
|
315
|
+
.run();
|
|
316
|
+
if ((result.meta?.changes ?? 0) === 0) {
|
|
317
|
+
throw new InvalidInputError(`Document ${collection}/${id} not found`, 'id');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Filter clauses shared by listDocuments and countDocuments so the two can
|
|
322
|
+
* never disagree. A data-field comparison checks `json_type` as well as the
|
|
323
|
+
* value: json_extract turns JSON true/false into 1/0, so value alone would
|
|
324
|
+
* make `false` match a stored `0`.
|
|
325
|
+
*/
|
|
326
|
+
buildFilterClauses(filter) {
|
|
327
|
+
const clauses = [];
|
|
328
|
+
const values = [];
|
|
329
|
+
if (!filter)
|
|
330
|
+
return { sql: '', values };
|
|
331
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
332
|
+
assertField(field);
|
|
333
|
+
const column = DOCUMENT_SYSTEM_COLUMNS[field];
|
|
334
|
+
if (column) {
|
|
335
|
+
if (value === null || value === undefined) {
|
|
336
|
+
clauses.push(`${column} IS NULL`);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
clauses.push(`${column} = ?`);
|
|
340
|
+
values.push(typeof value === 'boolean' ? boolColumn(value) : value);
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const path = jsonPath(field);
|
|
345
|
+
clauses.push(`json_extract(data, ?) IS ? AND json_type(data, ?) = ?`);
|
|
346
|
+
values.push(path, value === null || typeof value === 'object' ? JSON.stringify(value) : value, path, expectedJsonType(value));
|
|
347
|
+
}
|
|
348
|
+
return { sql: clauses.length ? ` AND ${clauses.join(' AND ')}` : '', values };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* ORDER BY terms. Data fields go through json_extract, which yields a real SQL
|
|
352
|
+
* number, so 2 sorts before 13; the text accessors would not.
|
|
353
|
+
*/
|
|
354
|
+
buildSortClause(sort) {
|
|
355
|
+
const terms = sort === undefined ? [] : Array.isArray(sort) ? sort : [sort];
|
|
356
|
+
if (terms.length === 0) {
|
|
357
|
+
return { sql: ' ORDER BY created_at DESC', values: [] };
|
|
358
|
+
}
|
|
359
|
+
const parts = [];
|
|
360
|
+
const values = [];
|
|
361
|
+
for (const term of terms) {
|
|
362
|
+
let field = term;
|
|
363
|
+
let direction = 'ASC';
|
|
364
|
+
if (field.startsWith('-')) {
|
|
365
|
+
direction = 'DESC';
|
|
366
|
+
field = field.slice(1);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
const suffix = /^(.+)\.(asc|desc)$/i.exec(field);
|
|
370
|
+
if (suffix) {
|
|
371
|
+
field = suffix[1];
|
|
372
|
+
direction = suffix[2].toUpperCase();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Validate after stripping, or '-title' reads as a field called '-title'.
|
|
376
|
+
assertField(field);
|
|
377
|
+
const column = DOCUMENT_SYSTEM_COLUMNS[field];
|
|
378
|
+
if (column) {
|
|
379
|
+
parts.push(`${column} ${direction}`);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
parts.push(`json_extract(data, ?) ${direction}`);
|
|
383
|
+
values.push(jsonPath(field));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { sql: ` ORDER BY ${parts.join(', ')}`, values };
|
|
387
|
+
}
|
|
388
|
+
async listDocuments(collection, options = {}) {
|
|
389
|
+
SecurityValidator.validateCollectionName(collection);
|
|
390
|
+
await this.ready();
|
|
391
|
+
const filter = this.buildFilterClauses(options.filter);
|
|
392
|
+
const sort = this.buildSortClause(options.sort);
|
|
393
|
+
// No implicit page size: an unbounded call returns everything up to the cap.
|
|
394
|
+
const limit = Math.min(options.limit ?? this.MAX_LIST_LIMIT, this.MAX_LIST_LIMIT);
|
|
395
|
+
const offset = options.offset ?? 0;
|
|
396
|
+
const result = await this.db
|
|
397
|
+
.prepare(`SELECT * FROM ${this.table('documents')} WHERE collection = ?${filter.sql}${sort.sql} LIMIT ? OFFSET ?`)
|
|
398
|
+
.bind(collection, ...filter.values, ...sort.values, limit, offset)
|
|
399
|
+
.all();
|
|
400
|
+
return (result.results ?? []).map(row => this.mapRowToDocument(row));
|
|
401
|
+
}
|
|
402
|
+
async countDocuments(collection, filter) {
|
|
403
|
+
SecurityValidator.validateCollectionName(collection);
|
|
404
|
+
await this.ready();
|
|
405
|
+
const clauses = this.buildFilterClauses(filter);
|
|
406
|
+
const row = await this.db
|
|
407
|
+
.prepare(`SELECT COUNT(*) AS count FROM ${this.table('documents')} WHERE collection = ?${clauses.sql}`)
|
|
408
|
+
.bind(collection, ...clauses.values)
|
|
409
|
+
.first();
|
|
410
|
+
return Number(row?.count ?? 0);
|
|
411
|
+
}
|
|
412
|
+
async documentExists(collection, id) {
|
|
413
|
+
SecurityValidator.validateCollectionName(collection);
|
|
414
|
+
SecurityValidator.validateDocumentId(id);
|
|
415
|
+
await this.ready();
|
|
416
|
+
const row = await this.db
|
|
417
|
+
.prepare(`SELECT 1 AS present FROM ${this.table('documents')} WHERE collection = ? AND id = ?`)
|
|
418
|
+
.bind(collection, id)
|
|
419
|
+
.first();
|
|
420
|
+
return row !== null && row !== undefined;
|
|
421
|
+
}
|
|
422
|
+
// ------------------------------------------------------------------- users
|
|
423
|
+
mapRowToUser(row) {
|
|
424
|
+
return {
|
|
425
|
+
id: row.id,
|
|
426
|
+
username: row.username,
|
|
427
|
+
email: row.email,
|
|
428
|
+
passwordHash: row.password_hash ?? '',
|
|
429
|
+
firstName: orUndefined(row.first_name),
|
|
430
|
+
lastName: orUndefined(row.last_name),
|
|
431
|
+
role: orUndefined(row.role),
|
|
432
|
+
// No `?? []` here: an absent column must read back as undefined. The empty
|
|
433
|
+
// array is a *create* default, applied when the row is written.
|
|
434
|
+
permissions: parseJsonColumn(row.permissions),
|
|
435
|
+
isActive: row.is_active === null || row.is_active === undefined ? true : row.is_active === 1,
|
|
436
|
+
profileImage: orUndefined(row.profile_image),
|
|
437
|
+
preferences: parseJsonColumn(row.preferences),
|
|
438
|
+
oauthProviders: parseJsonColumn(row.oauth_providers),
|
|
439
|
+
mfa: parseJsonColumn(row.mfa),
|
|
440
|
+
passkeys: parseJsonColumn(row.passkeys),
|
|
441
|
+
lastLoginAt: orUndefined(row.last_login_at),
|
|
442
|
+
createdAt: row.created_at,
|
|
443
|
+
updatedAt: row.updated_at
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
async getUser(id) {
|
|
447
|
+
await this.ready();
|
|
448
|
+
const row = await this.db
|
|
449
|
+
.prepare(`SELECT * FROM ${this.table('users')} WHERE id = ?`)
|
|
450
|
+
.bind(id)
|
|
451
|
+
.first();
|
|
452
|
+
return row ? this.mapRowToUser(row) : null;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Build the SET list from the keys actually present. A key holding `null`
|
|
456
|
+
* clears the column; a key that is absent, or present as `undefined`, leaves
|
|
457
|
+
* it alone. COALESCE cannot express that difference.
|
|
458
|
+
*/
|
|
459
|
+
buildUserAssignments(userData) {
|
|
460
|
+
const sql = [];
|
|
461
|
+
const values = [];
|
|
462
|
+
for (const { key, column, json, bool } of USER_UPDATE_COLUMNS) {
|
|
463
|
+
if (!(key in userData))
|
|
464
|
+
continue;
|
|
465
|
+
const value = userData[key];
|
|
466
|
+
if (value === undefined)
|
|
467
|
+
continue;
|
|
468
|
+
sql.push(`${column} = ?`);
|
|
469
|
+
if (value === null) {
|
|
470
|
+
values.push(null);
|
|
471
|
+
}
|
|
472
|
+
else if (json) {
|
|
473
|
+
values.push(jsonColumn(value));
|
|
474
|
+
}
|
|
475
|
+
else if (bool) {
|
|
476
|
+
values.push(boolColumn(value));
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
values.push(value);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return { sql, values };
|
|
483
|
+
}
|
|
484
|
+
async saveUser(id, userData) {
|
|
485
|
+
await this.ready();
|
|
486
|
+
const existing = await this.getUser(id);
|
|
487
|
+
const now = this.now();
|
|
488
|
+
const users = this.table('users');
|
|
489
|
+
const input = (userData ?? {});
|
|
490
|
+
if (existing) {
|
|
491
|
+
const assignments = this.buildUserAssignments(input);
|
|
492
|
+
assignments.sql.push('updated_at = ?');
|
|
493
|
+
assignments.values.push(now);
|
|
494
|
+
const row = await this.db
|
|
495
|
+
.prepare(`UPDATE ${users} SET ${assignments.sql.join(', ')} WHERE id = ? RETURNING *`)
|
|
496
|
+
.bind(...assignments.values, id)
|
|
497
|
+
.first();
|
|
498
|
+
if (!row)
|
|
499
|
+
throw new Error(`Failed to update user ${id}`);
|
|
500
|
+
return this.mapRowToUser(row);
|
|
501
|
+
}
|
|
502
|
+
const row = await this.db
|
|
503
|
+
.prepare(`INSERT INTO ${users} ${USER_INSERT_COLUMNS}
|
|
504
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
505
|
+
RETURNING *`)
|
|
506
|
+
.bind(...this.userInsertValues(id, input, now))
|
|
507
|
+
.first();
|
|
508
|
+
if (!row)
|
|
509
|
+
throw new Error(`Failed to create user ${id}`);
|
|
510
|
+
return this.mapRowToUser(row);
|
|
511
|
+
}
|
|
512
|
+
/** The bind list for a user INSERT, shared so the two insert paths cannot drift. */
|
|
513
|
+
userInsertValues(id, input, now) {
|
|
514
|
+
return [
|
|
515
|
+
id,
|
|
516
|
+
input.username ?? null,
|
|
517
|
+
input.email ?? null,
|
|
518
|
+
input.passwordHash ?? '',
|
|
519
|
+
input.firstName ?? null,
|
|
520
|
+
input.lastName ?? null,
|
|
521
|
+
input.role ?? null,
|
|
522
|
+
jsonColumn(input.permissions ?? []),
|
|
523
|
+
boolColumn(input.isActive === undefined ? true : input.isActive),
|
|
524
|
+
input.profileImage ?? null,
|
|
525
|
+
jsonColumn(input.preferences),
|
|
526
|
+
jsonColumn(input.oauthProviders),
|
|
527
|
+
jsonColumn(input.mfa),
|
|
528
|
+
jsonColumn(input.passkeys),
|
|
529
|
+
input.lastLoginAt ?? null,
|
|
530
|
+
now,
|
|
531
|
+
now
|
|
532
|
+
];
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Create the first user, atomically. SQLite serialises writers, so a single
|
|
536
|
+
* `INSERT ... SELECT ... WHERE NOT EXISTS` is the check and the write under one
|
|
537
|
+
* lock: of two concurrent calls, exactly one inserts and the other gets no row.
|
|
538
|
+
*/
|
|
539
|
+
async createFirstUser(id, userData) {
|
|
540
|
+
SecurityValidator.validateDocumentId(id);
|
|
541
|
+
await this.ready();
|
|
542
|
+
const users = this.table('users');
|
|
543
|
+
const values = this.userInsertValues(id, (userData ?? {}), this.now());
|
|
544
|
+
const row = await this.db
|
|
545
|
+
.prepare(`INSERT INTO ${users} ${USER_INSERT_COLUMNS}
|
|
546
|
+
SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
547
|
+
WHERE NOT EXISTS (SELECT 1 FROM ${users})
|
|
548
|
+
RETURNING *`)
|
|
549
|
+
.bind(...values)
|
|
550
|
+
.first();
|
|
551
|
+
return row ? this.mapRowToUser(row) : null;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Compare-and-swap in one statement. SQLite makes a single UPDATE atomic, so
|
|
555
|
+
* no transaction is needed: the WHERE clause is the comparison, and RETURNING
|
|
556
|
+
* proves whether it matched.
|
|
557
|
+
*/
|
|
558
|
+
async saveUserIf(id, userData, condition) {
|
|
559
|
+
await this.ready();
|
|
560
|
+
const assignments = this.buildUserAssignments((userData ?? {}));
|
|
561
|
+
assignments.sql.push('updated_at = ?');
|
|
562
|
+
assignments.values.push(this.now());
|
|
563
|
+
const row = await this.db
|
|
564
|
+
.prepare(`UPDATE ${this.table('users')} SET ${assignments.sql.join(', ')}
|
|
565
|
+
WHERE id = ? AND password_hash = ? RETURNING *`)
|
|
566
|
+
.bind(...assignments.values, id, condition.passwordHash)
|
|
567
|
+
.first();
|
|
568
|
+
return row ? this.mapRowToUser(row) : null;
|
|
569
|
+
}
|
|
570
|
+
async listUsers(options = {}) {
|
|
571
|
+
await this.ready();
|
|
572
|
+
const clauses = [];
|
|
573
|
+
const values = [];
|
|
574
|
+
if (options.role !== undefined) {
|
|
575
|
+
clauses.push('role = ?');
|
|
576
|
+
values.push(options.role);
|
|
577
|
+
}
|
|
578
|
+
if (options.isActive !== undefined) {
|
|
579
|
+
clauses.push('is_active = ?');
|
|
580
|
+
values.push(boolColumn(options.isActive));
|
|
581
|
+
}
|
|
582
|
+
const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
|
|
583
|
+
const limit = Math.min(options.limit ?? this.MAX_LIST_LIMIT, this.MAX_LIST_LIMIT);
|
|
584
|
+
const offset = options.offset ?? 0;
|
|
585
|
+
const result = await this.db
|
|
586
|
+
.prepare(`SELECT * FROM ${this.table('users')}${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
587
|
+
.bind(...values, limit, offset)
|
|
588
|
+
.all();
|
|
589
|
+
return (result.results ?? []).map(row => this.mapRowToUser(row));
|
|
590
|
+
}
|
|
591
|
+
async deleteUser(id) {
|
|
592
|
+
await this.ready();
|
|
593
|
+
const result = await this.db.prepare(`DELETE FROM ${this.table('users')} WHERE id = ?`).bind(id).run();
|
|
594
|
+
if ((result.meta?.changes ?? 0) === 0) {
|
|
595
|
+
throw new InvalidInputError(`User ${id} not found`, 'id');
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async findUserBy(column, value) {
|
|
599
|
+
if (!value || typeof value !== 'string') {
|
|
600
|
+
throw new InvalidInputError(`A ${column} is required`, column);
|
|
601
|
+
}
|
|
602
|
+
await this.ready();
|
|
603
|
+
const row = await this.db
|
|
604
|
+
.prepare(`SELECT * FROM ${this.table('users')} WHERE ${column} = ?`)
|
|
605
|
+
.bind(value)
|
|
606
|
+
.first();
|
|
607
|
+
return row ? this.mapRowToUser(row) : null;
|
|
608
|
+
}
|
|
609
|
+
async getUserByUsername(username) {
|
|
610
|
+
return this.findUserBy('username', username);
|
|
611
|
+
}
|
|
612
|
+
async getUserByEmail(email) {
|
|
613
|
+
return this.findUserBy('email', email);
|
|
614
|
+
}
|
|
615
|
+
async isUsernameAvailable(username) {
|
|
616
|
+
return (await this.getUserByUsername(username)) === null;
|
|
617
|
+
}
|
|
618
|
+
async isEmailAvailable(email) {
|
|
619
|
+
return (await this.getUserByEmail(email)) === null;
|
|
620
|
+
}
|
|
621
|
+
async getUserByOAuthProvider(provider, providerId) {
|
|
622
|
+
await this.ready();
|
|
623
|
+
const result = await this.db
|
|
624
|
+
.prepare(`SELECT * FROM ${this.table('users')} WHERE oauth_providers IS NOT NULL`)
|
|
625
|
+
.all();
|
|
626
|
+
for (const row of result.results ?? []) {
|
|
627
|
+
const providers = parseJsonColumn(row.oauth_providers) ?? [];
|
|
628
|
+
if (providers.some(p => p.provider === provider && String(p.providerId) === String(providerId))) {
|
|
629
|
+
return this.mapRowToUser(row);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
async getUserByPasskeyCredentialId(credentialId) {
|
|
635
|
+
if (!credentialId || !/^[A-Za-z0-9_-]+$/.test(credentialId)) {
|
|
636
|
+
throw new InvalidInputError('Invalid credential id', 'credentialId');
|
|
637
|
+
}
|
|
638
|
+
await this.ready();
|
|
639
|
+
const result = await this.db
|
|
640
|
+
.prepare(`SELECT * FROM ${this.table('users')} WHERE passkeys IS NOT NULL`)
|
|
641
|
+
.all();
|
|
642
|
+
for (const row of result.results ?? []) {
|
|
643
|
+
const passkeys = parseJsonColumn(row.passkeys) ?? [];
|
|
644
|
+
if (passkeys.some(p => p.credentialId === credentialId || p.id === credentialId)) {
|
|
645
|
+
return this.mapRowToUser(row);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
// -------------------------------------------------------------- app tokens
|
|
651
|
+
mapRowToAppToken(row) {
|
|
652
|
+
return {
|
|
653
|
+
id: row.id,
|
|
654
|
+
name: row.name,
|
|
655
|
+
description: orUndefined(row.description) ?? '',
|
|
656
|
+
hash: row.hash,
|
|
657
|
+
// Unlike a user's permissions, a token's are always an array: there is no
|
|
658
|
+
// "cleared" state for them, and the type says string[].
|
|
659
|
+
permissions: parseJsonColumn(row.permissions) ?? [],
|
|
660
|
+
createdBy: orUndefined(row.created_by),
|
|
661
|
+
isActive: row.is_active === null || row.is_active === undefined ? true : row.is_active === 1,
|
|
662
|
+
usageCount: Number(row.usage_count ?? 0),
|
|
663
|
+
lastUsedAt: orUndefined(row.last_used_at),
|
|
664
|
+
expiresAt: orUndefined(row.expires_at),
|
|
665
|
+
createdAt: row.created_at,
|
|
666
|
+
updatedAt: row.updated_at
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
async getAppToken(id) {
|
|
670
|
+
await this.ready();
|
|
671
|
+
const row = await this.db
|
|
672
|
+
.prepare(`SELECT * FROM ${this.table('app_tokens')} WHERE id = ?`)
|
|
673
|
+
.bind(id)
|
|
674
|
+
.first();
|
|
675
|
+
return row ? this.mapRowToAppToken(row) : null;
|
|
676
|
+
}
|
|
677
|
+
async getAppTokenByHash(hash) {
|
|
678
|
+
if (!hash || typeof hash !== 'string') {
|
|
679
|
+
throw new InvalidInputError('Token hash is required', 'hash');
|
|
680
|
+
}
|
|
681
|
+
await this.ready();
|
|
682
|
+
const row = await this.db
|
|
683
|
+
.prepare(`SELECT * FROM ${this.table('app_tokens')} WHERE hash = ? AND is_active = 1`)
|
|
684
|
+
.bind(hash)
|
|
685
|
+
.first();
|
|
686
|
+
if (!row)
|
|
687
|
+
return null;
|
|
688
|
+
await this.db
|
|
689
|
+
.prepare(`UPDATE ${this.table('app_tokens')} SET last_used_at = ?, usage_count = COALESCE(usage_count,0) + 1 WHERE id = ?`)
|
|
690
|
+
.bind(this.now(), row.id)
|
|
691
|
+
.run();
|
|
692
|
+
return this.mapRowToAppToken(row);
|
|
693
|
+
}
|
|
694
|
+
async saveAppToken(id, data) {
|
|
695
|
+
await this.ready();
|
|
696
|
+
const now = this.now();
|
|
697
|
+
const input = (data ?? {});
|
|
698
|
+
const tokens = this.table('app_tokens');
|
|
699
|
+
const existing = await this.getAppToken(id);
|
|
700
|
+
if (existing) {
|
|
701
|
+
const sql = [];
|
|
702
|
+
const values = [];
|
|
703
|
+
for (const [key, column, json, bool] of [
|
|
704
|
+
['name', 'name', false, false],
|
|
705
|
+
['description', 'description', false, false],
|
|
706
|
+
['permissions', 'permissions', true, false],
|
|
707
|
+
['isActive', 'is_active', false, true],
|
|
708
|
+
['expiresAt', 'expires_at', false, false],
|
|
709
|
+
['lastUsedAt', 'last_used_at', false, false]
|
|
710
|
+
]) {
|
|
711
|
+
if (!(key in input) || input[key] === undefined)
|
|
712
|
+
continue;
|
|
713
|
+
sql.push(`${column} = ?`);
|
|
714
|
+
values.push(json ? jsonColumn(input[key]) : bool ? boolColumn(input[key]) : input[key]);
|
|
715
|
+
}
|
|
716
|
+
sql.push('updated_at = ?');
|
|
717
|
+
values.push(now);
|
|
718
|
+
const row = await this.db
|
|
719
|
+
.prepare(`UPDATE ${tokens} SET ${sql.join(', ')} WHERE id = ? RETURNING *`)
|
|
720
|
+
.bind(...values, id)
|
|
721
|
+
.first();
|
|
722
|
+
if (!row)
|
|
723
|
+
throw new Error(`Failed to update app token ${id}`);
|
|
724
|
+
return this.mapRowToAppToken(row);
|
|
725
|
+
}
|
|
726
|
+
const row = await this.db
|
|
727
|
+
.prepare(`INSERT INTO ${tokens}
|
|
728
|
+
(id, name, description, hash, permissions, created_by, is_active,
|
|
729
|
+
usage_count, last_used_at, expires_at, created_at, updated_at)
|
|
730
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) RETURNING *`)
|
|
731
|
+
.bind(id, input.name ?? null, input.description ?? null, input.hash ?? null, jsonColumn(input.permissions ?? []), input.createdBy ?? null, boolColumn(input.isActive === undefined ? true : input.isActive), 0, input.lastUsedAt ?? null, input.expiresAt ?? null, now, now)
|
|
732
|
+
.first();
|
|
733
|
+
if (!row)
|
|
734
|
+
throw new Error(`Failed to create app token ${id}`);
|
|
735
|
+
return this.mapRowToAppToken(row);
|
|
736
|
+
}
|
|
737
|
+
async listAppTokens(options = {}) {
|
|
738
|
+
await this.ready();
|
|
739
|
+
const clauses = [];
|
|
740
|
+
const values = [];
|
|
741
|
+
if (options.createdBy !== undefined) {
|
|
742
|
+
clauses.push('created_by = ?');
|
|
743
|
+
values.push(options.createdBy);
|
|
744
|
+
}
|
|
745
|
+
if (options.isActive !== undefined) {
|
|
746
|
+
clauses.push('is_active = ?');
|
|
747
|
+
values.push(boolColumn(options.isActive));
|
|
748
|
+
}
|
|
749
|
+
const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
|
|
750
|
+
const limit = Math.min(options.limit ?? this.MAX_LIST_LIMIT, this.MAX_LIST_LIMIT);
|
|
751
|
+
const result = await this.db
|
|
752
|
+
.prepare(`SELECT * FROM ${this.table('app_tokens')}${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
753
|
+
.bind(...values, limit, options.offset ?? 0)
|
|
754
|
+
.all();
|
|
755
|
+
return (result.results ?? []).map(row => this.mapRowToAppToken(row));
|
|
756
|
+
}
|
|
757
|
+
async deleteAppToken(id) {
|
|
758
|
+
await this.ready();
|
|
759
|
+
const result = await this.db.prepare(`DELETE FROM ${this.table('app_tokens')} WHERE id = ?`).bind(id).run();
|
|
760
|
+
if ((result.meta?.changes ?? 0) === 0) {
|
|
761
|
+
throw new InvalidInputError(`App token ${id} not found`, 'id');
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// ------------------------------------------------------------- flow state
|
|
765
|
+
async saveAuthFlowState(state) {
|
|
766
|
+
SecurityValidator.validateDocumentId(state.id);
|
|
767
|
+
await this.ready();
|
|
768
|
+
await this.db
|
|
769
|
+
.prepare(`INSERT INTO ${this.table('auth_flow_state')} (id, kind, data, expires_at, created_at)
|
|
770
|
+
VALUES (?,?,?,?,?)
|
|
771
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
772
|
+
kind = excluded.kind, data = excluded.data, expires_at = excluded.expires_at`)
|
|
773
|
+
.bind(state.id, state.kind, JSON.stringify(state.data ?? {}), state.expiresAt, this.now())
|
|
774
|
+
.run();
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* One statement, so two concurrent callers cannot both win. Kind and expiry
|
|
778
|
+
* are part of the WHERE clause: presenting another flow's id neither returns
|
|
779
|
+
* it nor destroys it.
|
|
780
|
+
*/
|
|
781
|
+
async consumeAuthFlowState(id, kind) {
|
|
782
|
+
await this.ready();
|
|
783
|
+
const row = await this.db
|
|
784
|
+
.prepare(`DELETE FROM ${this.table('auth_flow_state')}
|
|
785
|
+
WHERE id = ? AND kind = ? AND expires_at > ?
|
|
786
|
+
RETURNING id, kind, data, expires_at`)
|
|
787
|
+
.bind(id, kind, this.now())
|
|
788
|
+
.first();
|
|
789
|
+
if (!row)
|
|
790
|
+
return null;
|
|
791
|
+
return {
|
|
792
|
+
id: row.id,
|
|
793
|
+
kind: row.kind,
|
|
794
|
+
data: parseJsonColumn(row.data) ?? {},
|
|
795
|
+
expiresAt: row.expires_at
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
async deleteExpiredAuthFlowStates() {
|
|
799
|
+
await this.ready();
|
|
800
|
+
const result = await this.db
|
|
801
|
+
.prepare(`DELETE FROM ${this.table('auth_flow_state')} WHERE expires_at <= ?`)
|
|
802
|
+
.bind(this.now())
|
|
803
|
+
.run();
|
|
804
|
+
return result.meta?.changes ?? 0;
|
|
805
|
+
}
|
|
806
|
+
// ---------------------------------------------------------------- settings
|
|
807
|
+
async getSettings() {
|
|
808
|
+
await this.ready();
|
|
809
|
+
const row = await this.db
|
|
810
|
+
.prepare(`SELECT data FROM ${this.table('settings')} WHERE id = 1`)
|
|
811
|
+
.first();
|
|
812
|
+
return row ? parseJsonColumn(row.data) ?? null : null;
|
|
813
|
+
}
|
|
814
|
+
async saveSettings(settings) {
|
|
815
|
+
await this.ready();
|
|
816
|
+
await this.db
|
|
817
|
+
.prepare(`INSERT INTO ${this.table('settings')} (id, data, updated_at) VALUES (1, ?, ?)
|
|
818
|
+
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`)
|
|
819
|
+
.bind(JSON.stringify(settings ?? {}), this.now())
|
|
820
|
+
.run();
|
|
821
|
+
}
|
|
822
|
+
async getStorageInfo() {
|
|
823
|
+
await this.ready();
|
|
824
|
+
const collections = await this.db
|
|
825
|
+
.prepare(`SELECT DISTINCT collection FROM ${this.table('documents')}`)
|
|
826
|
+
.all();
|
|
827
|
+
const counts = await this.db.batch([
|
|
828
|
+
this.db.prepare(`SELECT COUNT(*) AS count FROM ${this.table('documents')}`),
|
|
829
|
+
this.db.prepare(`SELECT COUNT(*) AS count FROM ${this.table('users')}`),
|
|
830
|
+
this.db.prepare(`SELECT COUNT(*) AS count FROM ${this.table('app_tokens')}`)
|
|
831
|
+
]);
|
|
832
|
+
const at = (index) => Number(counts[index]?.results?.[0]?.count ?? 0);
|
|
833
|
+
return {
|
|
834
|
+
type: 'cloudflare-d1',
|
|
835
|
+
collections: (collections.results ?? []).map(row => row.collection),
|
|
836
|
+
documentCount: at(0),
|
|
837
|
+
userCount: at(1),
|
|
838
|
+
tokenCount: at(2)
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
/** Not implemented: D1 has no interactive transactions. */
|
|
842
|
+
beginTransaction = undefined;
|
|
843
|
+
/** Escape hatch for callers that need raw SQL, e.g. migrations. */
|
|
844
|
+
async execute(sql, params = []) {
|
|
845
|
+
await this.ready();
|
|
846
|
+
const statement = params.length
|
|
847
|
+
? this.db.prepare(sql).bind(...params)
|
|
848
|
+
: this.db.prepare(sql);
|
|
849
|
+
const result = await statement.all();
|
|
850
|
+
return result.results ?? [];
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
//# sourceMappingURL=cloudflare-d1-adapter.js.map
|