@defrex/autobuild-postgres-store 0.11.0 → 0.12.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 +18 -1
- package/package.json +13 -1
- package/src/bin.ts +2 -2
- package/src/config.ts +29 -21
- package/src/connections.ts +160 -0
- package/src/credentials.ts +157 -0
- package/src/index.ts +5 -0
- package/src/projects.ts +171 -0
- package/src/schema.ts +167 -10
- package/src/ticket-assignments.ts +39 -0
- package/src/ticket-migration.ts +147 -0
- package/src/tickets.ts +41 -59
package/README.md
CHANGED
|
@@ -44,10 +44,27 @@ release checkout. The database identity needs permission to create tables,
|
|
|
44
44
|
constraints, and migration markers during migration and to select, insert,
|
|
45
45
|
update, and delete the resulting tables at runtime. The ticket schema has its
|
|
46
46
|
own version/checksum marker, so migrating an existing BuildStore v1 database
|
|
47
|
-
adds
|
|
47
|
+
adds project-scoped tickets, comments, and blockers without replacing the
|
|
48
48
|
established BuildStore marker. Opening against a missing, older/newer, or
|
|
49
49
|
checksum-mismatched schema fails; schema creation is never implicit.
|
|
50
50
|
|
|
51
|
+
Populated legacy ticket-v1 upgrades also require the running service's blob
|
|
52
|
+
backend, credentials, access mode and prefix. The environment-aware migration
|
|
53
|
+
CLI opens blobs lazily to read each registered project's selected effective
|
|
54
|
+
configuration when ownership must be inferred. Registered projects without a
|
|
55
|
+
run/config reference make no claim. Unreadable selected configs stop unchanged;
|
|
56
|
+
restoring their bytes or assigning every remaining legacy team unblocks migration.
|
|
57
|
+
Complete registered owner assignments avoid unrelated configuration reads.
|
|
58
|
+
Fresh installs, empty-v1 upgrades and v2 reruns remain SQL-only.
|
|
59
|
+
Released main-v9 upgrades import `AB_WEB_REPOSITORIES` once inside that same
|
|
60
|
+
transaction. On failure, main promotion and registry import roll back with
|
|
61
|
+
tickets. Owner `projects add` and `tickets assign-team` independently bootstrap
|
|
62
|
+
their registry/recovery journals on v9, so they remain usable after rollback.
|
|
63
|
+
Ambiguous teams stop unchanged; the owner resolves them with
|
|
64
|
+
`ab-hosted-store tickets assign-team TEAM REPOSITORY --user IDENTITY` before
|
|
65
|
+
rerunning migration. See the hosted-service migration procedure.
|
|
66
|
+
|
|
67
|
+
|
|
51
68
|
The current DDL of every marker — build store, ticket, and auth — is immutable
|
|
52
69
|
once a database has deployed it: deployed databases carry that exact DDL's
|
|
53
70
|
checksum, and editing the DDL in place leaves them all incompatible. Every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defrex/autobuild-postgres-store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -22,6 +22,18 @@
|
|
|
22
22
|
"./store": {
|
|
23
23
|
"types": "./src/store.ts",
|
|
24
24
|
"import": "./src/store.ts"
|
|
25
|
+
},
|
|
26
|
+
"./projects": {
|
|
27
|
+
"types": "./src/projects.ts",
|
|
28
|
+
"import": "./src/projects.ts"
|
|
29
|
+
},
|
|
30
|
+
"./credentials": {
|
|
31
|
+
"types": "./src/credentials.ts",
|
|
32
|
+
"import": "./src/credentials.ts"
|
|
33
|
+
},
|
|
34
|
+
"./connections": {
|
|
35
|
+
"types": "./src/connections.ts",
|
|
36
|
+
"import": "./src/connections.ts"
|
|
25
37
|
}
|
|
26
38
|
},
|
|
27
39
|
"bin": {
|
package/src/bin.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { describePostgresTarget, resolvePostgresUrl } from './env'
|
|
3
|
-
import {
|
|
3
|
+
import { migratePostgresFromEnv } from './config'
|
|
4
4
|
|
|
5
5
|
async function main(): Promise<void> {
|
|
6
6
|
if (process.argv.length !== 3 || process.argv[2] !== 'migrate') {
|
|
7
7
|
throw new Error('usage: ab-postgres-store migrate')
|
|
8
8
|
}
|
|
9
9
|
const url = resolvePostgresUrl(process.env)
|
|
10
|
-
await
|
|
10
|
+
await migratePostgresFromEnv(process.env)
|
|
11
11
|
console.log(`Autobuild PostgreSQL schema is ready on ${describePostgresTarget(url)}`)
|
|
12
12
|
}
|
|
13
13
|
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { migratePostgres } from './schema'
|
|
1
2
|
import { S3Client } from '@aws-sdk/client-s3'
|
|
2
|
-
import type { Clock } from '@defrex/autobuild/plugin-sdk'
|
|
3
|
+
import type { BlobStore, Clock } from '@defrex/autobuild/plugin-sdk'
|
|
3
4
|
import { resolvePostgresUrl } from './env'
|
|
4
5
|
import { S3BlobStore } from './s3'
|
|
5
6
|
import { openPostgresBuildStore, type PostgresBuildStore } from './store'
|
|
@@ -117,6 +118,14 @@ export async function openPostgresBuildStoreFromEnv(
|
|
|
117
118
|
...options,
|
|
118
119
|
...(config.retention ? { retention: config.retention } : {}),
|
|
119
120
|
}
|
|
121
|
+
return openPostgresBuildStore(config.url, openPostgresBlobs(config), storeOptions)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function openPostgresBlobsFromEnv(env: Env): BlobStore {
|
|
125
|
+
return openPostgresBlobs(parsePostgresStoreEnv(env))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function openPostgresBlobs(config: PostgresStoreConfig): BlobStore {
|
|
120
129
|
if (config.backend === 's3') {
|
|
121
130
|
const client = new S3Client({
|
|
122
131
|
region: config.region,
|
|
@@ -128,25 +137,24 @@ export async function openPostgresBuildStoreFromEnv(
|
|
|
128
137
|
...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
|
|
129
138
|
},
|
|
130
139
|
})
|
|
131
|
-
return
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
client,
|
|
135
|
-
bucket: config.bucket,
|
|
136
|
-
...(config.prefix ? { prefix: config.prefix } : {}),
|
|
137
|
-
}),
|
|
138
|
-
storeOptions,
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
return openPostgresBuildStore(
|
|
142
|
-
config.url,
|
|
143
|
-
new VercelBlobStore({
|
|
144
|
-
access: config.access,
|
|
140
|
+
return new S3BlobStore({
|
|
141
|
+
client,
|
|
142
|
+
bucket: config.bucket,
|
|
145
143
|
...(config.prefix ? { prefix: config.prefix } : {}),
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return new VercelBlobStore({
|
|
147
|
+
access: config.access,
|
|
148
|
+
...(config.prefix ? { prefix: config.prefix } : {}),
|
|
149
|
+
...(config.token
|
|
150
|
+
? { token: config.token }
|
|
151
|
+
: { oidcToken: config.oidcToken!, storeId: config.storeId! }),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function migratePostgresFromEnv(env: Env): Promise<void> {
|
|
156
|
+
await migratePostgres(resolvePostgresUrl(env), {
|
|
157
|
+
openBlobs: () => openPostgresBlobsFromEnv(env),
|
|
158
|
+
legacyProjectEnv: env,
|
|
159
|
+
})
|
|
152
160
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { insertPreparedCredential } from './credentials'
|
|
2
|
+
import { SQL } from 'bun'
|
|
3
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
|
+
import type { ConnectionProposal, ConnectionOutcome } from '@defrex/autobuild/connection'
|
|
5
|
+
import { normalizeProjectRepository, PostgresProjectRegistry } from './projects'
|
|
6
|
+
import { resolvePostgresUrl, type PostgresEnv } from './env'
|
|
7
|
+
import { assertSchema } from './schema'
|
|
8
|
+
|
|
9
|
+
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
10
|
+
export class ConnectionError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
readonly status: number,
|
|
13
|
+
message: string,
|
|
14
|
+
) {
|
|
15
|
+
super(message)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export interface ConnectionRecord extends ConnectionOutcome {
|
|
19
|
+
id: string
|
|
20
|
+
code: string
|
|
21
|
+
expiresAt: string
|
|
22
|
+
}
|
|
23
|
+
export interface ConnectionStore {
|
|
24
|
+
start(proposal: ConnectionProposal): Promise<ConnectionRecord & { capability: string }>
|
|
25
|
+
poll(id: string, capability: string, cancel?: boolean): Promise<ConnectionOutcome>
|
|
26
|
+
lookup(code: string, actor: string): Promise<ConnectionRecord | null>
|
|
27
|
+
decide(code: string, actor: string, approve: boolean): Promise<ConnectionRecord>
|
|
28
|
+
}
|
|
29
|
+
function record(row: Record<string, unknown>): ConnectionRecord {
|
|
30
|
+
return {
|
|
31
|
+
id: String(row.id),
|
|
32
|
+
repo: String(row.repo),
|
|
33
|
+
credentialId: String(row.credential_id),
|
|
34
|
+
code: String(row.code),
|
|
35
|
+
status: row.status as ConnectionOutcome['status'],
|
|
36
|
+
expiresAt: new Date(row.expires_at as string).toISOString(),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class PostgresConnectionStore implements ConnectionStore {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly sql: SQL,
|
|
42
|
+
private readonly now = () => new Date(),
|
|
43
|
+
) {}
|
|
44
|
+
async start(input: ConnectionProposal) {
|
|
45
|
+
if (
|
|
46
|
+
typeof input.repo !== 'string' ||
|
|
47
|
+
input.repo.length > 2048 ||
|
|
48
|
+
!/^[a-f0-9]{32}$/.test(input.credentialId) ||
|
|
49
|
+
!/^[a-f0-9]{64}$/.test(input.digest)
|
|
50
|
+
)
|
|
51
|
+
throw new ConnectionError(400, 'invalid connection proposal')
|
|
52
|
+
let repo: string
|
|
53
|
+
try {
|
|
54
|
+
repo = normalizeProjectRepository(input.repo)
|
|
55
|
+
} catch {
|
|
56
|
+
throw new ConnectionError(400, 'invalid repository identity')
|
|
57
|
+
}
|
|
58
|
+
const now = this.now()
|
|
59
|
+
// Bounded expiry retention; never deletes an active capability.
|
|
60
|
+
await this
|
|
61
|
+
.sql`DELETE FROM project_connections WHERE id IN (SELECT id FROM project_connections WHERE expires_at < ${new Date(now.getTime() - 86400000).toISOString()} LIMIT 100)`
|
|
62
|
+
const capability = randomBytes(32).toString('hex')
|
|
63
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
64
|
+
const id = randomBytes(16).toString('hex')
|
|
65
|
+
const code = randomBytes(5).toString('hex').toUpperCase()
|
|
66
|
+
const [row] = await this
|
|
67
|
+
.sql`INSERT INTO project_connections (id, repo, credential_id, digest, capability_hash, code, created_at, expires_at, status)
|
|
68
|
+
VALUES (${id}, ${repo}, ${input.credentialId}, ${input.digest}, ${hash(capability)}, ${code}, ${now.toISOString()}, ${new Date(now.getTime() + 600000).toISOString()}, 'pending') ON CONFLICT DO NOTHING RETURNING *`
|
|
69
|
+
if (row) return { ...record(row), capability }
|
|
70
|
+
}
|
|
71
|
+
throw new ConnectionError(503, 'connection creation unavailable; retry')
|
|
72
|
+
}
|
|
73
|
+
async poll(id: string, capability: string, cancel = false): Promise<ConnectionOutcome> {
|
|
74
|
+
if (!/^[a-f0-9]{32}$/.test(id) || !/^[a-f0-9]{64}$/.test(capability))
|
|
75
|
+
throw new ConnectionError(404, 'unknown connection')
|
|
76
|
+
return this.sql.begin(async (tx) => {
|
|
77
|
+
const [row] = await tx`SELECT * FROM project_connections WHERE id = ${id} FOR UPDATE`
|
|
78
|
+
if (
|
|
79
|
+
!row ||
|
|
80
|
+
!timingSafeEqual(
|
|
81
|
+
Buffer.from(row.capability_hash, 'hex'),
|
|
82
|
+
Buffer.from(hash(capability), 'hex'),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
throw new ConnectionError(404, 'unknown connection')
|
|
86
|
+
const now = this.now()
|
|
87
|
+
if (!cancel && row.polled_at && now.getTime() - new Date(row.polled_at).getTime() < 4500)
|
|
88
|
+
throw new ConnectionError(429, 'poll again in five seconds')
|
|
89
|
+
if (!cancel && row.poll_attempts >= 130)
|
|
90
|
+
throw new ConnectionError(429, 'connection polling limit reached')
|
|
91
|
+
let status = row.status
|
|
92
|
+
if (cancel) {
|
|
93
|
+
if (status === 'approved')
|
|
94
|
+
await tx`UPDATE project_credentials SET revoked_at = COALESCE(revoked_at, ${now.toISOString()}) WHERE id = ${row.credential_id} AND repo = ${row.repo}`
|
|
95
|
+
status = 'denied'
|
|
96
|
+
} else if (status === 'pending' && new Date(row.expires_at) <= now) status = 'expired'
|
|
97
|
+
await tx`UPDATE project_connections SET status = ${status}, polled_at = ${now.toISOString()}, poll_attempts = poll_attempts + 1 WHERE id = ${id}`
|
|
98
|
+
return { ...record(row), status }
|
|
99
|
+
}) as Promise<ConnectionOutcome>
|
|
100
|
+
}
|
|
101
|
+
async lookup(code: string, actor: string): Promise<ConnectionRecord | null> {
|
|
102
|
+
if (!actor || actor.length > 320 || !/^[A-F0-9]{10}$/.test(code))
|
|
103
|
+
throw new ConnectionError(400, 'enter the ten-character connection code')
|
|
104
|
+
const now = this.now()
|
|
105
|
+
const [limit] = await this
|
|
106
|
+
.sql`INSERT INTO connection_lookup_limits (actor, started_at, attempts) VALUES (${actor}, ${now.toISOString()}, 1)
|
|
107
|
+
ON CONFLICT (actor) DO UPDATE SET
|
|
108
|
+
attempts = CASE WHEN connection_lookup_limits.started_at < ${new Date(now.getTime() - 60000).toISOString()} THEN 1 ELSE connection_lookup_limits.attempts + 1 END,
|
|
109
|
+
started_at = CASE WHEN connection_lookup_limits.started_at < ${new Date(now.getTime() - 60000).toISOString()} THEN ${now.toISOString()} ELSE connection_lookup_limits.started_at END RETURNING attempts`
|
|
110
|
+
if (limit.attempts > 20)
|
|
111
|
+
throw new ConnectionError(429, 'too many code lookups; retry in a minute')
|
|
112
|
+
const [row] = await this.sql`SELECT * FROM project_connections WHERE code = ${code}`
|
|
113
|
+
if (!row) return null
|
|
114
|
+
const result = record(row)
|
|
115
|
+
if (result.status === 'pending' && new Date(result.expiresAt) <= now) result.status = 'expired'
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
async decide(code: string, actor: string, approve: boolean): Promise<ConnectionRecord> {
|
|
119
|
+
if (!(await this.lookup(code, actor))) throw new ConnectionError(404, 'unknown connection')
|
|
120
|
+
return this.sql.begin(async (tx) => {
|
|
121
|
+
const [row] = await tx`SELECT * FROM project_connections WHERE code = ${code} FOR UPDATE`
|
|
122
|
+
const now = this.now()
|
|
123
|
+
if (row.status !== 'pending') return record(row)
|
|
124
|
+
const status = new Date(row.expires_at) <= now ? 'expired' : approve ? 'approved' : 'denied'
|
|
125
|
+
if (status === 'approved') {
|
|
126
|
+
// Same lock order as registry mutations, with the request locked first.
|
|
127
|
+
await tx`INSERT INTO project_registry_state (singleton, legacy_imported) VALUES (true, false) ON CONFLICT DO NOTHING`
|
|
128
|
+
await tx`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
|
|
129
|
+
const [previous] =
|
|
130
|
+
await tx`SELECT action FROM project_journal WHERE repo = ${row.repo} ORDER BY seq DESC LIMIT 1`
|
|
131
|
+
if (previous?.action !== 'added')
|
|
132
|
+
await tx`INSERT INTO project_journal (seq, repo, action, actor, ts) VALUES (nextval('project_journal_seq'), ${row.repo}, 'added', ${actor}, ${now.toISOString()})`
|
|
133
|
+
// A collision aborts the whole transaction, including project registration.
|
|
134
|
+
await insertPreparedCredential(tx as SQL, {
|
|
135
|
+
id: row.credential_id,
|
|
136
|
+
digest: row.digest,
|
|
137
|
+
repo: row.repo,
|
|
138
|
+
now,
|
|
139
|
+
user: actor,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
const [updated] =
|
|
143
|
+
await tx`UPDATE project_connections SET status = ${status}, approved_by = ${status === 'approved' ? actor : null} WHERE id = ${row.id} RETURNING *`
|
|
144
|
+
return record(updated)
|
|
145
|
+
}) as Promise<ConnectionRecord>
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function openPostgresConnectionStoreFromEnv(
|
|
149
|
+
env: PostgresEnv,
|
|
150
|
+
): Promise<PostgresConnectionStore> {
|
|
151
|
+
const sql = new SQL(resolvePostgresUrl(env))
|
|
152
|
+
try {
|
|
153
|
+
await assertSchema(sql)
|
|
154
|
+
await new PostgresProjectRegistry(sql).initializeFromEnv(env)
|
|
155
|
+
return new PostgresConnectionStore(sql)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
await sql.close()
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import {
|
|
4
|
+
normalizeProjectRepository,
|
|
5
|
+
PostgresProjectRegistry,
|
|
6
|
+
type ProjectRegistryReader,
|
|
7
|
+
} from './projects'
|
|
8
|
+
import { resolvePostgresUrl, type PostgresEnv } from './env'
|
|
9
|
+
import { assertSchema } from './schema'
|
|
10
|
+
|
|
11
|
+
export interface CredentialMetadata {
|
|
12
|
+
id: string
|
|
13
|
+
repo: string
|
|
14
|
+
createdAt: string
|
|
15
|
+
createdBy: string
|
|
16
|
+
lastUsedAt: string | null
|
|
17
|
+
revokedAt: string | null
|
|
18
|
+
expiresAt: string | null
|
|
19
|
+
purpose: 'project-owner' | 'dispatcher'
|
|
20
|
+
}
|
|
21
|
+
export interface CredentialReader {
|
|
22
|
+
authenticate(token: string, now: Date): Promise<CredentialMetadata | null>
|
|
23
|
+
}
|
|
24
|
+
export interface CredentialStore extends CredentialReader {
|
|
25
|
+
issue(
|
|
26
|
+
repo: string,
|
|
27
|
+
user: string,
|
|
28
|
+
options?: { expiresAt?: Date; purpose?: CredentialMetadata['purpose'] },
|
|
29
|
+
): Promise<{ token: string; credential: CredentialMetadata }>
|
|
30
|
+
list(repo: string): Promise<CredentialMetadata[]>
|
|
31
|
+
revoke(repo: string, id: string, user: string): Promise<void>
|
|
32
|
+
close(): Promise<void>
|
|
33
|
+
}
|
|
34
|
+
export class ProjectNotRegisteredError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super('project not registered')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const hash = (secret: string) => createHash('sha256').update(secret).digest()
|
|
40
|
+
const stamp = (value: unknown): string | null =>
|
|
41
|
+
value == null ? null : new Date(value as string).toISOString()
|
|
42
|
+
function metadata(row: Record<string, unknown>): CredentialMetadata {
|
|
43
|
+
return {
|
|
44
|
+
id: String(row.id),
|
|
45
|
+
repo: String(row.repo),
|
|
46
|
+
createdAt: stamp(row.created_at)!,
|
|
47
|
+
createdBy: String(row.created_by),
|
|
48
|
+
lastUsedAt: stamp(row.last_used_at),
|
|
49
|
+
revokedAt: stamp(row.revoked_at),
|
|
50
|
+
expiresAt: stamp(row.expires_at),
|
|
51
|
+
purpose: row.purpose as CredentialMetadata['purpose'],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function actor(user: string): string {
|
|
55
|
+
if (!user.trim()) throw new Error('credential creator must be nonblank')
|
|
56
|
+
return user.trim()
|
|
57
|
+
}
|
|
58
|
+
/** Insert a CLI-prepared digest using the caller's transaction, never a raw token. */
|
|
59
|
+
export async function insertPreparedCredential(
|
|
60
|
+
sql: SQL,
|
|
61
|
+
input: {
|
|
62
|
+
id: string
|
|
63
|
+
digest: string
|
|
64
|
+
repo: string
|
|
65
|
+
user: string
|
|
66
|
+
now: Date
|
|
67
|
+
purpose?: CredentialMetadata['purpose']
|
|
68
|
+
expiresAt?: Date
|
|
69
|
+
},
|
|
70
|
+
): Promise<CredentialMetadata> {
|
|
71
|
+
if (!/^[a-f0-9]{32}$/.test(input.id) || !/^[a-f0-9]{64}$/.test(input.digest))
|
|
72
|
+
throw new Error('invalid prepared credential')
|
|
73
|
+
const repo = normalizeProjectRepository(input.repo),
|
|
74
|
+
by = actor(input.user)
|
|
75
|
+
const [row] =
|
|
76
|
+
await sql`INSERT INTO project_credentials (id, digest, repo, created_at, created_by, purpose, expires_at)
|
|
77
|
+
VALUES (${input.id}, ${input.digest}, ${repo}, ${input.now.toISOString()}, ${by}, ${input.purpose ?? 'project-owner'}, ${input.expiresAt?.toISOString() ?? null}) RETURNING *`
|
|
78
|
+
return metadata(row)
|
|
79
|
+
}
|
|
80
|
+
export class PostgresCredentialStore implements CredentialStore {
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly sql: SQL,
|
|
83
|
+
private readonly projects: ProjectRegistryReader,
|
|
84
|
+
private readonly ownsPool = false,
|
|
85
|
+
) {}
|
|
86
|
+
async issue(
|
|
87
|
+
raw: string,
|
|
88
|
+
user: string,
|
|
89
|
+
options: { expiresAt?: Date; purpose?: CredentialMetadata['purpose'] } = {},
|
|
90
|
+
): Promise<{ token: string; credential: CredentialMetadata }> {
|
|
91
|
+
const repo = normalizeProjectRepository(raw)
|
|
92
|
+
const by = actor(user)
|
|
93
|
+
if (!(await this.projects.has(repo))) throw new ProjectNotRegisteredError()
|
|
94
|
+
const now = new Date()
|
|
95
|
+
if (
|
|
96
|
+
options.expiresAt &&
|
|
97
|
+
(!Number.isFinite(options.expiresAt.getTime()) || options.expiresAt <= now)
|
|
98
|
+
)
|
|
99
|
+
throw new Error('credential expiry must be in the future')
|
|
100
|
+
const id = randomBytes(16).toString('hex')
|
|
101
|
+
const secret = randomBytes(32).toString('hex')
|
|
102
|
+
const credential = await insertPreparedCredential(this.sql, {
|
|
103
|
+
id,
|
|
104
|
+
digest: hash(secret).toString('hex'),
|
|
105
|
+
repo,
|
|
106
|
+
user: by,
|
|
107
|
+
now,
|
|
108
|
+
...options,
|
|
109
|
+
})
|
|
110
|
+
return { token: `abp_1_${id}_${secret}`, credential }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async authenticate(token: string, now: Date): Promise<CredentialMetadata | null> {
|
|
114
|
+
const parts = /^abp_1_([a-f0-9]{32})_([a-f0-9]{64})$/.exec(token)
|
|
115
|
+
if (!parts) return null
|
|
116
|
+
const [row] = await this.sql`SELECT * FROM project_credentials WHERE id = ${parts[1]!}`
|
|
117
|
+
if (
|
|
118
|
+
!row ||
|
|
119
|
+
!timingSafeEqual(hash(parts[2]!), Buffer.from(row.digest, 'hex')) ||
|
|
120
|
+
row.revoked_at ||
|
|
121
|
+
(row.expires_at && new Date(row.expires_at) <= now)
|
|
122
|
+
)
|
|
123
|
+
return null
|
|
124
|
+
if (!(await this.projects.has(row.repo))) throw new ProjectNotRegisteredError()
|
|
125
|
+
// The conditional update also closes a revoke racing the initial read.
|
|
126
|
+
const [used] = await this.sql`UPDATE project_credentials SET last_used_at = ${now.toISOString()}
|
|
127
|
+
WHERE id = ${row.id} AND revoked_at IS NULL RETURNING *`
|
|
128
|
+
return used ? metadata(used) : null
|
|
129
|
+
}
|
|
130
|
+
async list(raw: string): Promise<CredentialMetadata[]> {
|
|
131
|
+
const rows = await this
|
|
132
|
+
.sql`SELECT * FROM project_credentials WHERE repo = ${normalizeProjectRepository(raw)} ORDER BY created_at, id`
|
|
133
|
+
return rows.map(metadata)
|
|
134
|
+
}
|
|
135
|
+
async revoke(raw: string, id: string, user: string): Promise<void> {
|
|
136
|
+
actor(user)
|
|
137
|
+
await this
|
|
138
|
+
.sql`UPDATE project_credentials SET revoked_at = COALESCE(revoked_at, ${new Date().toISOString()}) WHERE repo = ${normalizeProjectRepository(raw)} AND id = ${id}`
|
|
139
|
+
}
|
|
140
|
+
async close(): Promise<void> {
|
|
141
|
+
if (this.ownsPool) await this.sql.close()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export async function openPostgresCredentialStoreFromEnv(
|
|
145
|
+
env: PostgresEnv,
|
|
146
|
+
): Promise<CredentialStore> {
|
|
147
|
+
const sql = new SQL(resolvePostgresUrl(env))
|
|
148
|
+
try {
|
|
149
|
+
await assertSchema(sql)
|
|
150
|
+
const projects = new PostgresProjectRegistry(sql)
|
|
151
|
+
await projects.initializeFromEnv(env)
|
|
152
|
+
return new PostgresCredentialStore(sql, projects, true)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await sql.close()
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -40,3 +40,8 @@ export {
|
|
|
40
40
|
export type { PostgresTicketContext, PostgresTicketLifecycle } from './tickets'
|
|
41
41
|
export { VercelBlobStore } from './vercel'
|
|
42
42
|
export type { VercelBlobStoreOptions } from './vercel'
|
|
43
|
+
export * from './projects'
|
|
44
|
+
export * from './credentials'
|
|
45
|
+
|
|
46
|
+
export { openPostgresBlobsFromEnv, migratePostgresFromEnv } from './config'
|
|
47
|
+
export * from './ticket-assignments'
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import { normalizeConnectionRepository as normalizeProjectRepository } from '@defrex/autobuild/connection'
|
|
3
|
+
import { resolvePostgresUrl, type PostgresEnv } from './env'
|
|
4
|
+
import { assertSchema, PROJECT_SCHEMA_DDL } from './schema'
|
|
5
|
+
|
|
6
|
+
export interface ProjectEntry {
|
|
7
|
+
repo: string
|
|
8
|
+
addedAt: string
|
|
9
|
+
addedBy: string
|
|
10
|
+
}
|
|
11
|
+
export interface ProjectRegistryReader {
|
|
12
|
+
list(): Promise<ProjectEntry[]>
|
|
13
|
+
has(repo: string): Promise<boolean>
|
|
14
|
+
}
|
|
15
|
+
export interface ProjectRegistry extends ProjectRegistryReader {
|
|
16
|
+
add(repo: string, user: string): Promise<ProjectEntry>
|
|
17
|
+
remove(repo: string, user: string): Promise<void>
|
|
18
|
+
close(): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Repository identities use the same normalization as checkout origins. */
|
|
22
|
+
export { normalizeConnectionRepository as normalizeProjectRepository } from '@defrex/autobuild/connection'
|
|
23
|
+
|
|
24
|
+
export function legacyProjectRepositories(env: PostgresEnv): string[] {
|
|
25
|
+
const raw = env.AB_WEB_REPOSITORIES?.trim()
|
|
26
|
+
if (!raw) return []
|
|
27
|
+
return [...new Set(raw.split(',').map(normalizeProjectRepository))]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function actor(user: string): string {
|
|
31
|
+
const value = user.trim()
|
|
32
|
+
if (!value) throw new Error('project administrator identity must be nonblank')
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
function entry(row: { repo: string; ts: Date | string; actor: string }): ProjectEntry {
|
|
36
|
+
return { repo: row.repo, addedAt: new Date(row.ts).toISOString(), addedBy: row.actor }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class PostgresProjectRegistry implements ProjectRegistry {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly sql: SQL,
|
|
42
|
+
private readonly ownsPool = false,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
private async locked<T>(work: (tx: SQL) => Promise<T>): Promise<T> {
|
|
46
|
+
return this.sql.begin(async (tx) => {
|
|
47
|
+
await tx`INSERT INTO project_registry_state (singleton, legacy_imported)
|
|
48
|
+
VALUES (true, false) ON CONFLICT (singleton) DO NOTHING`
|
|
49
|
+
await tx`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
|
|
50
|
+
return work(tx as SQL)
|
|
51
|
+
}) as Promise<T>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async initialize(repositories: readonly string[]): Promise<void> {
|
|
55
|
+
if (repositories.length === 0) return
|
|
56
|
+
await this.importOnce(() => repositories)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async initializeFromEnv(env: PostgresEnv): Promise<void> {
|
|
60
|
+
await this.importOnce(() => legacyProjectRepositories(env))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The migration owns this transaction; do not open a nested connection. */
|
|
64
|
+
async initializeFromEnvInTransaction(env: PostgresEnv): Promise<void> {
|
|
65
|
+
await this.sql`INSERT INTO project_registry_state (singleton, legacy_imported)
|
|
66
|
+
VALUES (true, false) ON CONFLICT (singleton) DO NOTHING`
|
|
67
|
+
await this.sql`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
|
|
68
|
+
await this.importIn(this.sql, () => legacyProjectRepositories(env))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async importOnce(source: () => readonly string[]): Promise<void> {
|
|
72
|
+
await this.locked((tx) => this.importIn(tx, source))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async importIn(tx: SQL, source: () => readonly string[]): Promise<void> {
|
|
76
|
+
const [state] =
|
|
77
|
+
await tx`SELECT legacy_imported FROM project_registry_state WHERE singleton = true`
|
|
78
|
+
if (state.legacy_imported) return
|
|
79
|
+
// Read the retired environment only until the one-time import succeeds.
|
|
80
|
+
const normalized = source().map(normalizeProjectRepository)
|
|
81
|
+
if (normalized.length === 0) return
|
|
82
|
+
for (const repo of normalized) await this.addIn(tx, repo, 'system:AB_WEB_REPOSITORIES')
|
|
83
|
+
await tx`UPDATE project_registry_state SET legacy_imported = true WHERE singleton = true`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async list(): Promise<ProjectEntry[]> {
|
|
87
|
+
const rows = await this.sql`SELECT repo, actor, ts FROM (
|
|
88
|
+
SELECT DISTINCT ON (repo) repo, action, actor, ts FROM project_journal ORDER BY repo, seq DESC
|
|
89
|
+
) latest WHERE action = 'added' ORDER BY repo`
|
|
90
|
+
return rows.map(entry)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async has(repo: string): Promise<boolean> {
|
|
94
|
+
const [row] = await this
|
|
95
|
+
.sql`SELECT action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
|
|
96
|
+
return row?.action === 'added'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async addIn(tx: SQL, repo: string, user: string): Promise<ProjectEntry> {
|
|
100
|
+
const [previous] =
|
|
101
|
+
await tx`SELECT repo, action, actor, ts FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
|
|
102
|
+
if (previous?.action === 'added') return entry(previous)
|
|
103
|
+
const [row] = await tx`INSERT INTO project_journal (seq, repo, action, actor, ts)
|
|
104
|
+
VALUES (nextval('project_journal_seq'), ${repo}, 'added', ${user}, ${new Date().toISOString()})
|
|
105
|
+
RETURNING repo, actor, ts`
|
|
106
|
+
return entry(row)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async add(raw: string, user: string): Promise<ProjectEntry> {
|
|
110
|
+
const repo = normalizeProjectRepository(raw)
|
|
111
|
+
const by = actor(user)
|
|
112
|
+
return this.locked((tx) => this.addIn(tx, repo, by))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async remove(raw: string, user: string): Promise<void> {
|
|
116
|
+
const repo = normalizeProjectRepository(raw)
|
|
117
|
+
const by = actor(user)
|
|
118
|
+
await this.locked(async (tx) => {
|
|
119
|
+
const [previous] =
|
|
120
|
+
await tx`SELECT action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
|
|
121
|
+
if (previous?.action !== 'added') return
|
|
122
|
+
await tx`INSERT INTO project_journal (seq, repo, action, actor, ts)
|
|
123
|
+
VALUES (nextval('project_journal_seq'), ${repo}, 'removed', ${by}, ${new Date().toISOString()})`
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async close(): Promise<void> {
|
|
128
|
+
if (this.ownsPool) await this.sql.close()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function openPostgresProjectRegistryFromEnv(
|
|
133
|
+
env: PostgresEnv,
|
|
134
|
+
): Promise<ProjectRegistry> {
|
|
135
|
+
const sql = new SQL(resolvePostgresUrl(env))
|
|
136
|
+
try {
|
|
137
|
+
await assertSchema(sql)
|
|
138
|
+
const registry = new PostgresProjectRegistry(sql, true)
|
|
139
|
+
await registry.initializeFromEnv(env)
|
|
140
|
+
return registry
|
|
141
|
+
} catch (error) {
|
|
142
|
+
await sql.close()
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Owner recovery must work while the main-schema promotion is rolled back by
|
|
148
|
+
* a stopped ticket migration. Bootstrap only the independent registry tables. */
|
|
149
|
+
export async function initializeProjectRegistryForRecovery(
|
|
150
|
+
sql: SQL,
|
|
151
|
+
env: PostgresEnv,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
await sql.begin(async (tx) => {
|
|
154
|
+
await tx`SELECT pg_advisory_xact_lock(470281941)`
|
|
155
|
+
await tx.unsafe(PROJECT_SCHEMA_DDL)
|
|
156
|
+
await new PostgresProjectRegistry(tx as SQL).initializeFromEnvInTransaction(env)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function openPostgresProjectRegistryForRecoveryFromEnv(
|
|
161
|
+
env: PostgresEnv,
|
|
162
|
+
): Promise<ProjectRegistry> {
|
|
163
|
+
const sql = new SQL(resolvePostgresUrl(env))
|
|
164
|
+
try {
|
|
165
|
+
await initializeProjectRegistryForRecovery(sql, env)
|
|
166
|
+
return new PostgresProjectRegistry(sql, true)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
await sql.close()
|
|
169
|
+
throw error
|
|
170
|
+
}
|
|
171
|
+
}
|