@crombolo/data 0.11.0 → 0.13.1
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/package.json +2 -2
- package/src/ambient.ts +20 -0
- package/src/busy.ts +74 -0
- package/src/change-report.ts +6 -0
- package/src/cluster.ts +17 -5
- package/src/database.ts +46 -13
- package/src/index.ts +17 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -0
- package/src/repositories/approvals.ts +277 -0
- package/src/repositories/data-types.ts +38 -0
- package/src/repositories/delivery-member-tokens.ts +110 -0
- package/src/repositories/dictionary.ts +18 -0
- package/src/repositories/documents.ts +431 -86
- package/src/repositories/dynamic-roots.ts +151 -0
- package/src/repositories/form-entries.ts +32 -11
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +11 -0
- package/src/repositories/nodes.ts +11 -0
- package/src/repositories/public-access.ts +30 -0
- package/src/repositories/redirects.ts +30 -45
- package/src/repositories/references.ts +15 -0
- package/src/repositories/transfer-answers.ts +151 -0
- package/src/repositories/webhooks.ts +299 -0
- package/src/schema/approvals.ts +77 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +11 -0
- package/src/schema/seed.ts +16 -4
- package/src/schema/transfer-answers.ts +50 -0
- package/src/schema/webhooks.ts +78 -0
- package/src/setup.ts +57 -0
- package/src/tree-invariants.ts +167 -0
- package/src/url-options.ts +61 -0
- package/src/workflow-store.ts +114 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Delivery API's member tokens. A token is random and opaque; only its
|
|
3
|
+
* SHA-256 is kept, so the table cannot be replayed from a backup.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
6
|
+
import type { Db } from '../database.ts'
|
|
7
|
+
import { DbDate } from '../dialect.ts'
|
|
8
|
+
|
|
9
|
+
export type MemberTokenKind = 'code' | 'access' | 'refresh'
|
|
10
|
+
|
|
11
|
+
export interface MemberToken {
|
|
12
|
+
kind: MemberTokenKind
|
|
13
|
+
memberKey: string
|
|
14
|
+
clientId: string
|
|
15
|
+
scopes: string[]
|
|
16
|
+
redirectUri: string | null
|
|
17
|
+
codeChallenge: string | null
|
|
18
|
+
expiresAt: Date
|
|
19
|
+
redeemedAt: Date | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A new token for a client to hold, and the hash it is stored under. Pure apart from the randomness. */
|
|
23
|
+
export function newToken(): { token: string; hash: string } {
|
|
24
|
+
const token = randomBytes(32).toString('base64url')
|
|
25
|
+
return { token, hash: hashToken(token) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex')
|
|
29
|
+
|
|
30
|
+
/** PKCE S256: the verifier's SHA-256, base64url, against the challenge. Pure. */
|
|
31
|
+
export const pkceMatches = (verifier: string, challenge: string): boolean =>
|
|
32
|
+
createHash('sha256').update(verifier).digest('base64url') === challenge
|
|
33
|
+
|
|
34
|
+
export class DeliveryMemberTokenRepository {
|
|
35
|
+
#db: Db
|
|
36
|
+
constructor(db: Db) {
|
|
37
|
+
this.#db = db
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async issue(
|
|
41
|
+
token: Omit<MemberToken, 'redeemedAt' | 'expiresAt'> & { lifetimeMs: number },
|
|
42
|
+
): Promise<{ token: string; expiresAt: Date }> {
|
|
43
|
+
const { token: value, hash } = newToken()
|
|
44
|
+
const now = new Date()
|
|
45
|
+
const expiresAt = new Date(now.getTime() + token.lifetimeMs)
|
|
46
|
+
await this.#db.exec(
|
|
47
|
+
`INSERT INTO delivery_member_token
|
|
48
|
+
(hash, kind, member_key, client_id, scopes, redirect_uri, code_challenge, expires_at, create_date)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
50
|
+
[
|
|
51
|
+
hash,
|
|
52
|
+
token.kind,
|
|
53
|
+
token.memberKey,
|
|
54
|
+
token.clientId,
|
|
55
|
+
token.scopes.join(' '),
|
|
56
|
+
token.redirectUri,
|
|
57
|
+
token.codeChallenge,
|
|
58
|
+
DbDate.toDb(expiresAt),
|
|
59
|
+
DbDate.toDb(now),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
return { token: value, expiresAt }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The token a client presents, whatever its state; the caller judges it. */
|
|
66
|
+
async find(token: string): Promise<MemberToken | undefined> {
|
|
67
|
+
const rows = await this.#db.query('SELECT * FROM delivery_member_token WHERE hash = ?', [
|
|
68
|
+
hashToken(token),
|
|
69
|
+
])
|
|
70
|
+
const row = rows[0]
|
|
71
|
+
if (!row) return undefined
|
|
72
|
+
return {
|
|
73
|
+
kind: String(row.kind) as MemberTokenKind,
|
|
74
|
+
memberKey: String(row.member_key),
|
|
75
|
+
clientId: String(row.client_id),
|
|
76
|
+
scopes: String(row.scopes ?? '')
|
|
77
|
+
.split(' ')
|
|
78
|
+
.filter(Boolean),
|
|
79
|
+
redirectUri: (row.redirect_uri as string | null) ?? null,
|
|
80
|
+
codeChallenge: (row.code_challenge as string | null) ?? null,
|
|
81
|
+
expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
|
|
82
|
+
redeemedAt: DbDate.fromDb(row.redeemed_at) ?? null,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Marks a code or refresh token used; false when it already was, so only one redemption wins. */
|
|
87
|
+
async redeem(token: string): Promise<boolean> {
|
|
88
|
+
const rows = await this.#db.query<{ hash: string }>(
|
|
89
|
+
'UPDATE delivery_member_token SET redeemed_at = ? WHERE hash = ? AND redeemed_at IS NULL RETURNING hash',
|
|
90
|
+
[DbDate.toDb(new Date()), hashToken(token)],
|
|
91
|
+
)
|
|
92
|
+
return rows.length > 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async revoke(token: string): Promise<void> {
|
|
96
|
+
await this.#db.exec('DELETE FROM delivery_member_token WHERE hash = ?', [hashToken(token)])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every token a member holds, as signing out ends them. */
|
|
100
|
+
async revokeMember(memberKey: string): Promise<void> {
|
|
101
|
+
await this.#db.exec('DELETE FROM delivery_member_token WHERE member_key = ?', [memberKey])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Expired tokens, which nothing will accept again. */
|
|
105
|
+
async purgeExpired(): Promise<void> {
|
|
106
|
+
await this.#db.exec('DELETE FROM delivery_member_token WHERE expires_at < ?', [
|
|
107
|
+
DbDate.toDb(new Date()),
|
|
108
|
+
])
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -240,6 +240,24 @@ export class DictionaryRepository {
|
|
|
240
240
|
})
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/** Every item's key, name and parent's name, for a choice list drawn from the dictionary. */
|
|
244
|
+
async outline(): Promise<Array<{ key: string; name: string; parentName: string | null }>> {
|
|
245
|
+
const rows = await this.#db.query<{
|
|
246
|
+
key: string
|
|
247
|
+
item_key: string
|
|
248
|
+
parent_name: string | null
|
|
249
|
+
}>(
|
|
250
|
+
`SELECT d.key, d.item_key, p.item_key AS parent_name FROM dictionary_item d
|
|
251
|
+
LEFT JOIN dictionary_item p ON p.key = d.parent_key
|
|
252
|
+
ORDER BY d.item_key`,
|
|
253
|
+
)
|
|
254
|
+
return rows.map((row) => ({
|
|
255
|
+
key: normaliseUuid(String(row.key)),
|
|
256
|
+
name: String(row.item_key),
|
|
257
|
+
parentName: row.parent_name === null ? null : String(row.parent_name),
|
|
258
|
+
}))
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
/** Every translation, keyed by item name then iso code, for rendering. */
|
|
244
262
|
async translations(): Promise<Map<string, Map<string, string>>> {
|
|
245
263
|
const rows = await this.#db.query<{ item_key: string; iso_code: string; value: string }>(
|