velocious 1.0.474 → 1.0.475

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.
Files changed (85) hide show
  1. package/README.md +7 -2
  2. package/build/configuration-types.js +38 -1
  3. package/build/configuration.js +41 -1
  4. package/build/database/drivers/base.js +51 -3
  5. package/build/database/drivers/mysql/index.js +74 -7
  6. package/build/database/drivers/sqlite/connection-sql-js.js +16 -5
  7. package/build/database/drivers/sqlite/index.web.js +29 -15
  8. package/build/database/drivers/sqlite/web-persistence.js +521 -0
  9. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
  10. package/build/frontend-model-controller.js +127 -4
  11. package/build/frontend-model-resource/base-resource.js +5 -1
  12. package/build/frontend-models/base.js +146 -2
  13. package/build/frontend-models/query.js +3 -3
  14. package/build/frontend-models/resource-definition.js +304 -2
  15. package/build/routes/hooks/frontend-model-command-route-hook.js +8 -0
  16. package/build/src/configuration-types.d.ts +113 -2
  17. package/build/src/configuration-types.d.ts.map +1 -1
  18. package/build/src/configuration-types.js +35 -2
  19. package/build/src/configuration.d.ts +18 -1
  20. package/build/src/configuration.d.ts.map +1 -1
  21. package/build/src/configuration.js +37 -2
  22. package/build/src/database/drivers/base.d.ts +30 -0
  23. package/build/src/database/drivers/base.d.ts.map +1 -1
  24. package/build/src/database/drivers/base.js +47 -4
  25. package/build/src/database/drivers/mysql/index.d.ts +32 -3
  26. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  27. package/build/src/database/drivers/mysql/index.js +65 -7
  28. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +8 -1
  29. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  30. package/build/src/database/drivers/sqlite/connection-sql-js.js +15 -6
  31. package/build/src/database/drivers/sqlite/index.web.d.ts +5 -5
  32. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  33. package/build/src/database/drivers/sqlite/index.web.js +27 -13
  34. package/build/src/database/drivers/sqlite/web-persistence.d.ts +77 -0
  35. package/build/src/database/drivers/sqlite/web-persistence.d.ts.map +1 -0
  36. package/build/src/database/drivers/sqlite/web-persistence.js +464 -0
  37. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +30 -0
  38. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  39. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +53 -1
  40. package/build/src/frontend-model-controller.d.ts +34 -0
  41. package/build/src/frontend-model-controller.d.ts.map +1 -1
  42. package/build/src/frontend-model-controller.js +113 -5
  43. package/build/src/frontend-model-resource/base-resource.d.ts +4 -2
  44. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  45. package/build/src/frontend-model-resource/base-resource.js +7 -2
  46. package/build/src/frontend-models/base.d.ts +29 -0
  47. package/build/src/frontend-models/base.d.ts.map +1 -1
  48. package/build/src/frontend-models/base.js +131 -3
  49. package/build/src/frontend-models/query.js +4 -4
  50. package/build/src/frontend-models/resource-definition.d.ts +6 -0
  51. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  52. package/build/src/frontend-models/resource-definition.js +272 -3
  53. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  54. package/build/src/routes/hooks/frontend-model-command-route-hook.js +8 -1
  55. package/build/src/sync/device-identity.d.ts +240 -0
  56. package/build/src/sync/device-identity.d.ts.map +1 -0
  57. package/build/src/sync/device-identity.js +454 -0
  58. package/build/src/sync/local-mutation-log.d.ts +212 -0
  59. package/build/src/sync/local-mutation-log.d.ts.map +1 -0
  60. package/build/src/sync/local-mutation-log.js +404 -0
  61. package/build/src/sync/offline-grant.d.ts +157 -0
  62. package/build/src/sync/offline-grant.d.ts.map +1 -0
  63. package/build/src/sync/offline-grant.js +292 -0
  64. package/build/sync/device-identity.js +503 -0
  65. package/build/sync/local-mutation-log.js +448 -0
  66. package/build/sync/offline-grant.js +327 -0
  67. package/build/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +3 -3
  69. package/src/configuration-types.js +38 -1
  70. package/src/configuration.js +41 -1
  71. package/src/database/drivers/base.js +51 -3
  72. package/src/database/drivers/mysql/index.js +74 -7
  73. package/src/database/drivers/sqlite/connection-sql-js.js +16 -5
  74. package/src/database/drivers/sqlite/index.web.js +29 -15
  75. package/src/database/drivers/sqlite/web-persistence.js +521 -0
  76. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
  77. package/src/frontend-model-controller.js +127 -4
  78. package/src/frontend-model-resource/base-resource.js +5 -1
  79. package/src/frontend-models/base.js +146 -2
  80. package/src/frontend-models/query.js +3 -3
  81. package/src/frontend-models/resource-definition.js +304 -2
  82. package/src/routes/hooks/frontend-model-command-route-hook.js +8 -0
  83. package/src/sync/device-identity.js +503 -0
  84. package/src/sync/local-mutation-log.js +448 -0
  85. package/src/sync/offline-grant.js +327 -0
@@ -0,0 +1,327 @@
1
+ // @ts-check
2
+
3
+ const OFFLINE_GRANT_SIGNATURE_ALGORITHM = "HS256"
4
+ const OFFLINE_GRANT_SIGNATURE_PREFIX = "hmac-sha256-"
5
+
6
+ /**
7
+ * Signed offline grant envelope.
8
+ * @typedef {object} SignedOfflineGrant
9
+ * @property {"HS256"} algorithm - Signature algorithm.
10
+ * @property {OfflineGrant} grant - Signed grant payload.
11
+ * @property {string} keyId - Signing key id.
12
+ * @property {string} signature - Hex HMAC signature with a hmac-sha256 prefix.
13
+ */
14
+
15
+ /**
16
+ * Backend-issued offline grant payload.
17
+ * @typedef {object} OfflineGrant
18
+ * @property {string} deviceId - Device id allowed to use the grant.
19
+ * @property {string} expiresAt - ISO timestamp after which replay must reject the grant.
20
+ * @property {string} grantId - Stable grant id.
21
+ * @property {string} issuedAt - ISO timestamp when the backend issued the grant.
22
+ * @property {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} resources - Sync manifest/materialized resource metadata.
23
+ * @property {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} scopes - Materialized grant scopes.
24
+ * @property {string} userId - Actor user id allowed to use the grant.
25
+ */
26
+
27
+ /**
28
+ * Offline grant signing key.
29
+ * @typedef {object} OfflineGrantSigningKey
30
+ * @property {boolean} [current] - Whether this is the active key for new grants.
31
+ * @property {string} id - Public key id included in signed grant envelopes.
32
+ * @property {string} secret - Private HMAC secret. Never expose this to clients.
33
+ */
34
+
35
+ /**
36
+ * Creates a signed offline grant envelope.
37
+ * @param {object} args - Arguments.
38
+ * @param {OfflineGrant} args.grant - Grant payload.
39
+ * @param {OfflineGrantSigningKey} args.signingKey - Current signing key.
40
+ * @returns {Promise<SignedOfflineGrant>} - Signed grant envelope.
41
+ */
42
+ export async function createOfflineGrant({grant, signingKey}) {
43
+ const normalizedGrant = normalizeOfflineGrant(grant)
44
+ const normalizedSigningKey = normalizeOfflineGrantSigningKey(signingKey)
45
+ const signatureInput = offlineGrantSignatureInput({
46
+ algorithm: OFFLINE_GRANT_SIGNATURE_ALGORITHM,
47
+ grant: normalizedGrant,
48
+ keyId: normalizedSigningKey.id
49
+ })
50
+
51
+ return {
52
+ algorithm: OFFLINE_GRANT_SIGNATURE_ALGORITHM,
53
+ grant: normalizedGrant,
54
+ keyId: normalizedSigningKey.id,
55
+ signature: await hmacSha256Hex({message: signatureInput, secret: normalizedSigningKey.secret})
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Verifies a signed offline grant and returns the trusted grant payload.
61
+ * @param {object} args - Arguments.
62
+ * @param {Date} [args.now] - Verification time. Defaults to current time.
63
+ * @param {SignedOfflineGrant} args.signedGrant - Signed grant envelope.
64
+ * @param {OfflineGrantSigningKey[]} args.signingKeys - Candidate verification keys.
65
+ * @returns {Promise<OfflineGrant>} - Verified grant payload.
66
+ */
67
+ export async function verifyOfflineGrant({now = new Date(), signedGrant, signingKeys}) {
68
+ const normalizedSignedGrant = normalizeSignedOfflineGrant(signedGrant)
69
+
70
+ if (normalizedSignedGrant.algorithm !== OFFLINE_GRANT_SIGNATURE_ALGORITHM) {
71
+ throw new Error(`Unsupported offline grant signature algorithm '${normalizedSignedGrant.algorithm}'`)
72
+ }
73
+
74
+ const signingKey = signingKeys
75
+ .map((key) => normalizeOfflineGrantSigningKey(key))
76
+ .find((key) => key.id === normalizedSignedGrant.keyId)
77
+
78
+ if (!signingKey) throw new Error(`No offline grant signing key for key id '${normalizedSignedGrant.keyId}'`)
79
+
80
+ const expectedSignature = await hmacSha256Hex({
81
+ message: offlineGrantSignatureInput(normalizedSignedGrant),
82
+ secret: signingKey.secret
83
+ })
84
+
85
+ if (expectedSignature !== normalizedSignedGrant.signature) {
86
+ throw new Error("Offline grant signature did not match")
87
+ }
88
+
89
+ if (Number.isNaN(now.getTime())) throw new Error("Invalid offline grant verification time")
90
+ if (new Date(normalizedSignedGrant.grant.expiresAt).getTime() <= now.getTime()) throw new Error("Offline grant expired")
91
+
92
+ return normalizedSignedGrant.grant
93
+ }
94
+
95
+ /**
96
+ * Builds a signed grant from bootstrap request inputs.
97
+ * @param {object} args - Arguments.
98
+ * @param {string} args.deviceId - Device id.
99
+ * @param {string | undefined} [args.grantId] - Optional deterministic grant id for tests/custom callers.
100
+ * @param {number | undefined} [args.grantTtlMs] - Grant TTL in milliseconds.
101
+ * @param {Date} args.now - Issue time.
102
+ * @param {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} args.resources - Resource sync manifest.
103
+ * @param {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} args.scopes - Grant scopes.
104
+ * @param {OfflineGrantSigningKey} args.signingKey - Current signing key.
105
+ * @param {string} args.userId - Actor user id.
106
+ * @returns {Promise<SignedOfflineGrant>} - Signed grant.
107
+ */
108
+ export async function createOfflineGrantFromBootstrap({deviceId, grantId, grantTtlMs, now, resources, scopes, signingKey, userId}) {
109
+ const issuedAt = isoDateString(now, "issuedAt")
110
+ const ttlMs = grantTtlMs === undefined ? 24 * 60 * 60 * 1000 : grantTtlMs
111
+
112
+ if (!Number.isInteger(ttlMs) || ttlMs <= 0) throw new Error("Offline grant TTL must be a positive integer number of milliseconds")
113
+
114
+ return await createOfflineGrant({
115
+ grant: {
116
+ deviceId,
117
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
118
+ grantId: grantId || randomGrantId(),
119
+ issuedAt,
120
+ resources,
121
+ scopes,
122
+ userId
123
+ },
124
+ signingKey
125
+ })
126
+ }
127
+
128
+ /**
129
+ * Normalizes signed grant signature input.
130
+ * @param {{algorithm: "HS256", grant: OfflineGrant, keyId: string}} signedGrant - Grant signature fields.
131
+ * @returns {string} - Stable signature input.
132
+ */
133
+ function offlineGrantSignatureInput(signedGrant) {
134
+ return stableJsonStringify({
135
+ algorithm: signedGrant.algorithm,
136
+ grant: signedGrant.grant,
137
+ keyId: signedGrant.keyId
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Normalizes a signed offline grant envelope.
143
+ * @param {SignedOfflineGrant} signedGrant - Signed grant.
144
+ * @returns {SignedOfflineGrant} - Normalized signed grant.
145
+ */
146
+ function normalizeSignedOfflineGrant(signedGrant) {
147
+ if (!signedGrant || typeof signedGrant !== "object" || Array.isArray(signedGrant)) throw new Error("Expected signed offline grant object")
148
+ if (signedGrant.algorithm !== OFFLINE_GRANT_SIGNATURE_ALGORITHM) throw new Error(`Unsupported offline grant signature algorithm '${String(signedGrant.algorithm)}'`)
149
+ if (typeof signedGrant.keyId !== "string" || signedGrant.keyId.length < 1) throw new Error("Expected offline grant key id")
150
+ if (typeof signedGrant.signature !== "string" || !signedGrant.signature.match(/^hmac-sha256-[a-f0-9]{64}$/)) throw new Error("Expected offline grant signature")
151
+
152
+ return {
153
+ algorithm: signedGrant.algorithm,
154
+ grant: normalizeOfflineGrant(signedGrant.grant),
155
+ keyId: signedGrant.keyId,
156
+ signature: signedGrant.signature
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Normalizes an offline grant payload.
162
+ * @param {OfflineGrant} grant - Grant payload.
163
+ * @returns {OfflineGrant} - Normalized grant payload.
164
+ */
165
+ function normalizeOfflineGrant(grant) {
166
+ if (!grant || typeof grant !== "object" || Array.isArray(grant)) throw new Error("Expected offline grant object")
167
+
168
+ return {
169
+ deviceId: requiredString(grant.deviceId, "deviceId"),
170
+ expiresAt: isoDateString(new Date(requiredString(grant.expiresAt, "expiresAt")), "expiresAt"),
171
+ grantId: requiredString(grant.grantId, "grantId"),
172
+ issuedAt: isoDateString(new Date(requiredString(grant.issuedAt, "issuedAt")), "issuedAt"),
173
+ resources: deterministicJsonObject({label: "resources", value: grant.resources}),
174
+ scopes: deterministicJsonObject({label: "scopes", value: grant.scopes}),
175
+ userId: requiredString(grant.userId, "userId")
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Normalizes an offline grant signing key.
181
+ * @param {OfflineGrantSigningKey} signingKey - Signing key.
182
+ * @returns {OfflineGrantSigningKey} - Normalized signing key.
183
+ */
184
+ export function normalizeOfflineGrantSigningKey(signingKey) {
185
+ if (!signingKey || typeof signingKey !== "object" || Array.isArray(signingKey)) throw new Error("Expected offline grant signing key object")
186
+
187
+ return {
188
+ current: signingKey.current === true,
189
+ id: requiredString(signingKey.id, "signingKey.id"),
190
+ secret: requiredString(signingKey.secret, "signingKey.secret")
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Returns the current key used to sign new grants.
196
+ * @param {OfflineGrantSigningKey[]} signingKeys - Configured signing keys.
197
+ * @returns {OfflineGrantSigningKey} - Current signing key.
198
+ */
199
+ export function currentOfflineGrantSigningKey(signingKeys) {
200
+ const normalizedKeys = signingKeys.map((key) => normalizeOfflineGrantSigningKey(key))
201
+ const currentKeys = normalizedKeys.filter((key) => key.current === true)
202
+
203
+ if (normalizedKeys.length < 1) throw new Error("At least one offline grant signing key is required")
204
+ if (currentKeys.length > 1) throw new Error("Only one offline grant signing key can be current")
205
+
206
+ return currentKeys[0] || normalizedKeys[0]
207
+ }
208
+
209
+ /**
210
+ * Signs a message using HMAC-SHA256 and returns a prefixed hex signature.
211
+ * @param {object} args - Arguments.
212
+ * @param {string} args.message - Message to sign.
213
+ * @param {string} args.secret - HMAC secret.
214
+ * @returns {Promise<string>} - Prefixed hex signature.
215
+ */
216
+ async function hmacSha256Hex({message, secret}) {
217
+ const cryptoProvider = globalThis.crypto
218
+
219
+ if (!cryptoProvider?.subtle) throw new Error("WebCrypto subtle API is required for offline grant signing")
220
+
221
+ const key = await cryptoProvider.subtle.importKey(
222
+ "raw",
223
+ new TextEncoder().encode(secret),
224
+ {hash: "SHA-256", name: "HMAC"},
225
+ false,
226
+ ["sign"]
227
+ )
228
+ const signature = await cryptoProvider.subtle.sign("HMAC", key, new TextEncoder().encode(message))
229
+
230
+ return `${OFFLINE_GRANT_SIGNATURE_PREFIX}${hex(new Uint8Array(signature))}`
231
+ }
232
+
233
+ /**
234
+ * Converts bytes to lower-case hex.
235
+ * @param {Uint8Array} bytes - Bytes.
236
+ * @returns {string} - Hex string.
237
+ */
238
+ function hex(bytes) {
239
+ return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join("")
240
+ }
241
+
242
+ /**
243
+ * Requires a non-empty string field.
244
+ * @param {unknown} value - Value.
245
+ * @param {string} name - Field name.
246
+ * @returns {string} - String value.
247
+ */
248
+ function requiredString(value, name) {
249
+ if (typeof value !== "string" || value.length < 1) throw new Error(`Expected offline grant ${name}`)
250
+
251
+ return value
252
+ }
253
+
254
+ /**
255
+ * Normalizes a Date to an ISO timestamp.
256
+ * @param {Date} date - Date value.
257
+ * @param {string} label - Field label.
258
+ * @returns {string} - ISO timestamp.
259
+ */
260
+ function isoDateString(date, label) {
261
+ if (Number.isNaN(date.getTime())) throw new Error(`Invalid offline grant ${label}`)
262
+
263
+ return date.toISOString()
264
+ }
265
+
266
+ /**
267
+ * Generates a fallback random grant id.
268
+ * @returns {string} - Grant id.
269
+ */
270
+ function randomGrantId() {
271
+ return globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : `grant-${Date.now()}-${Math.random().toString(16).slice(2)}`
272
+ }
273
+
274
+ /**
275
+ * Stable JSON stringifier with sorted object keys.
276
+ * @param {unknown} value - Value.
277
+ * @returns {string} - JSON string.
278
+ */
279
+ function stableJsonStringify(value) {
280
+ return JSON.stringify(deterministicJson({label: "root", value}))
281
+ }
282
+
283
+ /**
284
+ * Normalizes a value as a JSON object.
285
+ * @param {object} args - Arguments.
286
+ * @param {string} args.label - Diagnostic label.
287
+ * @param {unknown} args.value - Value.
288
+ * @returns {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} - JSON object.
289
+ */
290
+ function deterministicJsonObject({label, value}) {
291
+ const normalized = deterministicJson({label, value})
292
+
293
+ if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) throw new Error(`Expected offline grant ${label} object`)
294
+
295
+ return /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (normalized)
296
+ }
297
+
298
+ /**
299
+ * Normalizes deterministic JSON with sorted object keys.
300
+ * @param {object} args - Arguments.
301
+ * @param {string} args.label - Diagnostic label.
302
+ * @param {unknown} args.value - Value.
303
+ * @returns {import("../configuration-types.js").FrontendModelSyncJsonValue} - Normalized JSON value.
304
+ */
305
+ function deterministicJson({label, value}) {
306
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value
307
+
308
+ if (Array.isArray(value)) {
309
+ return value.map((entry, index) => deterministicJson({label: `${label}/${index}`, value: entry}))
310
+ }
311
+
312
+ if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
313
+ /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */
314
+ const normalized = {}
315
+
316
+ for (const key of Object.keys(value).sort()) {
317
+ const childValue = /** @type {Record<string, unknown>} */ (value)[key]
318
+
319
+ if (childValue === undefined) continue
320
+ normalized[key] = deterministicJson({label: `${label}/${key}`, value: childValue})
321
+ }
322
+
323
+ return normalized
324
+ }
325
+
326
+ throw new Error(`Offline grant ${label} must be deterministic JSON`)
327
+ }