@crombolo/data 0.14.1 → 0.14.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/data",
3
- "version": "0.14.1",
3
+ "version": "0.14.4",
4
4
  "description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,6 +28,6 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.14.1"
31
+ "@crombolo/core": "0.14.4"
32
32
  }
33
33
  }
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from './repositories/notifications.ts'
33
33
  export * from './repositories/public-access.ts'
34
34
  export * from './repositories/redirects.ts'
35
35
  export * from './repositories/references.ts'
36
+ export * from './repositories/remote-jobs.ts'
36
37
  export * from './repositories/tags.ts'
37
38
  export * from './repositories/templates.ts'
38
39
  export * from './repositories/transfer-answers.ts'
@@ -67,6 +68,7 @@ export * from './schema/plan.ts'
67
68
  export * from './schema/property-searchable.ts'
68
69
  export * from './schema/public-access.ts'
69
70
  export * from './schema/recycle-bin.ts'
71
+ export * from './schema/remote.ts'
70
72
  export * from './schema/rich-text-ui.ts'
71
73
  export * from './schema/search-index.ts'
72
74
  export * from './schema/seed.ts'
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Pulls and pushes through the remote protocol (story 107): one row per job,
3
+ * read by whichever node a client polls, written by the node running it.
4
+ */
5
+ import type { Db } from '../database.ts'
6
+ import { DbDate } from '../dialect.ts'
7
+
8
+ export type RemoteJobKind = 'pull' | 'push'
9
+ export type RemoteJobState =
10
+ | 'queued'
11
+ | 'running'
12
+ | 'approval-required'
13
+ | 'done'
14
+ | 'failed'
15
+ | 'refused'
16
+
17
+ export interface RemoteJob {
18
+ id: string
19
+ kind: RemoteJobKind
20
+ environment: string
21
+ requestedBy: string
22
+ requestedByName: string
23
+ options: Record<string, unknown>
24
+ state: RemoteJobState
25
+ stage: string | null
26
+ progress: number | null
27
+ fileKey: string | null
28
+ fileSize: number | null
29
+ sha256: string | null
30
+ problem: { code: string; title: string; detail?: string; items?: string[] } | null
31
+ secret: string | null
32
+ runId: string | null
33
+ decidedBy: string | null
34
+ decidedAt: Date | undefined
35
+ heartbeatAt: Date
36
+ expiresAt: Date
37
+ createdAt: Date
38
+ }
39
+
40
+ const parse = <T>(value: unknown, fallback: T): T => {
41
+ if (typeof value !== 'string' || value === '') return fallback
42
+ try {
43
+ return JSON.parse(value) as T
44
+ } catch {
45
+ return fallback
46
+ }
47
+ }
48
+
49
+ function mapJob(row: Record<string, unknown>): RemoteJob {
50
+ const str = (v: unknown) => (v === null || v === undefined ? null : String(v))
51
+ return {
52
+ id: String(row.id),
53
+ kind: String(row.kind) as RemoteJobKind,
54
+ environment: String(row.environment),
55
+ requestedBy: String(row.requested_by),
56
+ requestedByName: String(row.requested_by_name),
57
+ options: parse(row.options, {}),
58
+ state: String(row.state) as RemoteJobState,
59
+ stage: str(row.stage),
60
+ progress: row.progress === null || row.progress === undefined ? null : Number(row.progress),
61
+ fileKey: str(row.file_key),
62
+ fileSize: row.file_size === null || row.file_size === undefined ? null : Number(row.file_size),
63
+ sha256: str(row.sha256),
64
+ problem: parse(row.problem, null),
65
+ secret: str(row.secret),
66
+ runId: str(row.run_id),
67
+ decidedBy: str(row.decided_by),
68
+ decidedAt: DbDate.fromDb(row.decided_at),
69
+ heartbeatAt: DbDate.fromDb(row.heartbeat_at) ?? new Date(0),
70
+ expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
71
+ createdAt: DbDate.fromDb(row.create_date) ?? new Date(0),
72
+ }
73
+ }
74
+
75
+ export type RemoteJobUpdate = Partial<
76
+ Pick<
77
+ RemoteJob,
78
+ | 'state'
79
+ | 'stage'
80
+ | 'progress'
81
+ | 'fileKey'
82
+ | 'fileSize'
83
+ | 'sha256'
84
+ | 'problem'
85
+ | 'secret'
86
+ | 'runId'
87
+ | 'decidedBy'
88
+ | 'decidedAt'
89
+ | 'expiresAt'
90
+ >
91
+ >
92
+
93
+ const COLUMNS: Record<keyof RemoteJobUpdate, string> = {
94
+ state: 'state',
95
+ stage: 'stage',
96
+ progress: 'progress',
97
+ fileKey: 'file_key',
98
+ fileSize: 'file_size',
99
+ sha256: 'sha256',
100
+ problem: 'problem',
101
+ secret: 'secret',
102
+ runId: 'run_id',
103
+ decidedBy: 'decided_by',
104
+ decidedAt: 'decided_at',
105
+ expiresAt: 'expires_at',
106
+ }
107
+
108
+ export class RemoteJobRepository {
109
+ #db: Db
110
+
111
+ constructor(db: Db) {
112
+ this.#db = db
113
+ }
114
+
115
+ async create(job: {
116
+ id: string
117
+ kind: RemoteJobKind
118
+ environment: string
119
+ requestedBy: string
120
+ requestedByName: string
121
+ options: Record<string, unknown>
122
+ state: RemoteJobState
123
+ expiresAt: Date
124
+ }): Promise<void> {
125
+ const now = DbDate.toDb(new Date())
126
+ await this.#db.exec(
127
+ `INSERT INTO remote_job
128
+ (id, kind, environment, requested_by, requested_by_name, options, state, heartbeat_at, expires_at, create_date)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
130
+ [
131
+ job.id,
132
+ job.kind,
133
+ job.environment,
134
+ job.requestedBy,
135
+ job.requestedByName,
136
+ JSON.stringify(job.options),
137
+ job.state,
138
+ now,
139
+ DbDate.toDb(job.expiresAt),
140
+ now,
141
+ ],
142
+ )
143
+ }
144
+
145
+ async get(id: string): Promise<RemoteJob | undefined> {
146
+ const rows = await this.#db.query('SELECT * FROM remote_job WHERE id = ?', [id])
147
+ return rows[0] ? mapJob(rows[0]) : undefined
148
+ }
149
+
150
+ /** Records progress, and that the node running it is still alive. */
151
+ async update(id: string, change: RemoteJobUpdate): Promise<void> {
152
+ const sets: string[] = ['heartbeat_at = ?']
153
+ const values: unknown[] = [DbDate.toDb(new Date())]
154
+ for (const [field, value] of Object.entries(change) as [keyof RemoteJobUpdate, unknown][]) {
155
+ sets.push(`${COLUMNS[field]} = ?`)
156
+ values.push(
157
+ field === 'problem'
158
+ ? value === null
159
+ ? null
160
+ : JSON.stringify(value)
161
+ : value instanceof Date
162
+ ? DbDate.toDb(value)
163
+ : value,
164
+ )
165
+ }
166
+ await this.#db.exec(`UPDATE remote_job SET ${sets.join(', ')} WHERE id = ?`, [...values, id])
167
+ }
168
+
169
+ /** Moves a job from one state to another only if it is still in the first; true when it moved. */
170
+ async transition(id: string, from: RemoteJobState, change: RemoteJobUpdate): Promise<boolean> {
171
+ const rows = await this.#db.query<{ id: string }>(
172
+ 'UPDATE remote_job SET state = ?, heartbeat_at = ? WHERE id = ? AND state = ? RETURNING id',
173
+ [change.state ?? from, DbDate.toDb(new Date()), id, from],
174
+ )
175
+ if (rows.length === 0) return false
176
+ await this.update(id, change)
177
+ return true
178
+ }
179
+
180
+ /** Jobs past their expiry, whose files are to be swept. */
181
+ async expired(now = new Date()): Promise<RemoteJob[]> {
182
+ const rows = await this.#db.query('SELECT * FROM remote_job WHERE expires_at < ?', [
183
+ DbDate.toDb(now),
184
+ ])
185
+ return rows.map(mapJob)
186
+ }
187
+
188
+ async delete(id: string): Promise<void> {
189
+ await this.#db.exec('DELETE FROM remote_job WHERE id = ?', [id])
190
+ }
191
+ }
@@ -40,6 +40,7 @@ import { propertySearchableMigration } from './property-searchable.ts'
40
40
  import { publicAccessMigration } from './public-access.ts'
41
41
  import { recycleBinMigration } from './recycle-bin.ts'
42
42
  import { redirectsMigration } from './redirects.ts'
43
+ import { remoteMigration } from './remote.ts'
43
44
  import { richTextUiMigration } from './rich-text-ui.ts'
44
45
  import { searchIndexMigration } from './search-index.ts'
45
46
  import { serverRoleMigration } from './server-role.ts'
@@ -95,4 +96,5 @@ export const cromboloPlan = new MigrationPlan([
95
96
  approvalsMigration,
96
97
  siteSearchMigration,
97
98
  twoFactorMigration,
99
+ remoteMigration,
98
100
  ])
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Migration 041 — the remote protocol (story 107). docs/remote-protocol.md.
3
+ *
4
+ * `device_authorization` is a sign-in by the OAuth device grant (RFC 8628):
5
+ * the device code's hash, the code a person types, who approved it, and the
6
+ * session its tokens belong to — which is what "signed-in devices" lists and
7
+ * revokes. `remote_job` is a pull or a push: who asked, where it has got to,
8
+ * the file it made or received in the media store, and the approval a
9
+ * protected push may wait for — with its passphrase sealed under
10
+ * `CROMBOLO_SECRET_KEY` meanwhile, since the import cannot run without it. Grants the four remote verbs to the
11
+ * administrators group.
12
+ */
13
+ import type { Db } from '../database.ts'
14
+ import type { Migration } from '../migrations.ts'
15
+ import { grantBuiltInPermissions } from './seed.ts'
16
+ import { STATE_TWO_FACTOR } from './two-factor.ts'
17
+
18
+ export const STATE_REMOTE = '{2b7e5c91-0041-4f3a-8d26-9c4a1e7b3f51}'
19
+
20
+ export const remoteMigration: Migration = {
21
+ from: STATE_TWO_FACTOR,
22
+ to: STATE_REMOTE,
23
+ name: 'AddRemote',
24
+ kind: 'expand',
25
+ release: '0.14.4',
26
+ async up(db: Db) {
27
+ const t = db.dialect.types
28
+ await db.exec(
29
+ `CREATE TABLE device_authorization (
30
+ id ${t.identity},
31
+ device_code_hash ${t.varchar(64)} NOT NULL UNIQUE,
32
+ user_code ${t.varchar(16)} NOT NULL UNIQUE,
33
+ client_id ${t.varchar(255)} NOT NULL,
34
+ client_name ${t.varchar(255)},
35
+ scope ${t.text} NOT NULL,
36
+ status ${t.varchar(16)} NOT NULL,
37
+ user_id ${t.integer} REFERENCES user_account (id),
38
+ session_id ${t.uuid},
39
+ interval_seconds ${t.integer} NOT NULL,
40
+ last_polled_at ${t.timestamp},
41
+ expires_at ${t.timestamp} NOT NULL,
42
+ decided_at ${t.timestamp},
43
+ create_date ${t.timestamp} NOT NULL
44
+ )`,
45
+ )
46
+ await db.exec(
47
+ 'CREATE INDEX ix_device_authorization_session ON device_authorization (session_id)',
48
+ )
49
+ await db.exec(
50
+ `CREATE TABLE remote_job (
51
+ id ${t.varchar(36)} PRIMARY KEY,
52
+ kind ${t.varchar(8)} NOT NULL,
53
+ environment ${t.varchar(64)} NOT NULL,
54
+ requested_by ${t.varchar(36)} NOT NULL,
55
+ requested_by_name ${t.varchar(255)} NOT NULL,
56
+ options ${t.text} NOT NULL,
57
+ state ${t.varchar(24)} NOT NULL,
58
+ stage ${t.varchar(64)},
59
+ progress ${t.integer},
60
+ file_key ${t.text},
61
+ file_size ${t.bigint},
62
+ sha256 ${t.varchar(64)},
63
+ problem ${t.text},
64
+ secret ${t.text},
65
+ run_id ${t.varchar(36)},
66
+ decided_by ${t.varchar(36)},
67
+ decided_at ${t.timestamp},
68
+ heartbeat_at ${t.timestamp} NOT NULL,
69
+ expires_at ${t.timestamp} NOT NULL,
70
+ create_date ${t.timestamp} NOT NULL
71
+ )`,
72
+ )
73
+ await db.exec('CREATE INDEX ix_remote_job_state ON remote_job (state)')
74
+ await grantBuiltInPermissions(db)
75
+ },
76
+ }
@@ -4,6 +4,7 @@
4
4
  * Password hashing is injected rather than imported so this package stays free of
5
5
  * a dependency on @crombolo/auth.
6
6
  */
7
+ import { RemotePermissions } from '@crombolo/core'
7
8
  import type { Db } from '../database.ts'
8
9
  import { DbDate } from '../dialect.ts'
9
10
 
@@ -65,6 +66,7 @@ export const BUILT_IN_GROUP_PERMISSIONS: Record<string, readonly string[]> = {
65
66
  'Umb.Document.PropertyValue.Write',
66
67
  ...FORM_FULL,
67
68
  ...ELEMENT_EDIT,
69
+ ...Object.values(RemotePermissions),
68
70
  ],
69
71
  editor: [
70
72
  'Umb.Document.Create',
package/src/setup.ts CHANGED
@@ -55,3 +55,17 @@ export function completeSetup(
55
55
  }),
56
56
  )
57
57
  }
58
+
59
+ /**
60
+ * Closes setup without creating an administrator: a machine can administer the
61
+ * site instead (story 034's seeded API client). Answers whether it was pending.
62
+ */
63
+ export function closeSetup(db: Db): Promise<boolean> {
64
+ return db.locks.withLock(Locks.KeyValues, () =>
65
+ db.transaction(async (tx) => {
66
+ if ((await readSetupState(tx)) !== 'pending') return false
67
+ await writeSetupState(tx, 'complete')
68
+ return true
69
+ }),
70
+ )
71
+ }