@inixiative/archive 0.7.0 → 0.9.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 +21 -5
- package/package.json +4 -3
- package/src/cli.ts +64 -38
- package/src/client.ts +64 -36
- package/src/config.ts +33 -44
- package/src/kingdom.ts +121 -0
- package/src/remote.ts +6 -2
package/README.md
CHANGED
|
@@ -68,19 +68,35 @@ archive agents status
|
|
|
68
68
|
|
|
69
69
|
## Connect through Kingdom
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
Kingdom is the permission hub. Your machine's Archive talks directly only to local pieces, such as your local Foundry. It reaches a hosted Archive through Kingdom, by presenting a Signet.
|
|
72
|
+
|
|
73
|
+
First, pair this Archive with Kingdom:
|
|
72
74
|
|
|
73
75
|
```sh
|
|
74
|
-
bun run archive
|
|
76
|
+
bun run archive pair --kingdom https://api.your-kingdom.example --name "Work laptop"
|
|
75
77
|
```
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
`pair` registers this Archive with Kingdom as an **Installation**, named by a key kept in `<home>/kingdom/<kingdom host>/`. The same key is reused for every owner it registers with. It then asks to be registered as an integration and prints a review code with a link.
|
|
80
|
+
|
|
81
|
+
Open the link, choose the owner and the hosted Archives this machine may write to, and approve. The command waits for approval, then shows which owner it was registered with and asks you to accept. Pass `--yes` to accept without the prompt; without a terminal, `--yes` is required. Only then does it collect the Signet (0600) and list the libraries it may write to. Confirming the owner protects you if someone else claimed your code into their own owner.
|
|
82
|
+
|
|
83
|
+
Next, route a project to one of those libraries:
|
|
78
84
|
|
|
79
85
|
```sh
|
|
80
|
-
bun run archive connect --kind kingdom --url https://your-kingdom.example --
|
|
86
|
+
bun run archive connect --kind kingdom --url https://api.your-kingdom.example --credential-file ~/.archive/kingdom/signet-SIGNET.json --integration-id HOSTED_ARCHIVE_INTEGRATION --resource-id LIBRARY --project-id inixiative
|
|
81
87
|
```
|
|
82
88
|
|
|
83
|
-
|
|
89
|
+
`connect` checks that the Signet grants `sessions.write` on that library before it saves anything. `serve --sync` then sends each changed session through `POST /api/v1/access/execute`. Every call carries the Signet's access token and a fresh DPoP proof (`@inixiative/signet` renews the token).
|
|
90
|
+
|
|
91
|
+
Kingdom enforces the grant on every write:
|
|
92
|
+
|
|
93
|
+
- It accepts only snapshots whose `sourceId` is this Archive's.
|
|
94
|
+
- It records the paired integration on the session's actor.
|
|
95
|
+
- It resolves a revision conflict from this source by writing over the hosted head. Older captures are still refused.
|
|
96
|
+
|
|
97
|
+
Each write is usage on the Signet. Revoke the Signet, or pause the local Archive integration in Kingdom, to stop it. `search --remote` uses `documents.search` when the Signet grants it.
|
|
98
|
+
|
|
99
|
+
Kingdom keeps no archive store. It forwards each write to the hosted Archive server, whose own token stays in Kingdom as an encrypted credential.
|
|
84
100
|
|
|
85
101
|
Foundry writes its captures to the local Archive server through `@inixiative/archive/remote`.
|
|
86
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/archive",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
"check": "bun run typecheck && bun run lint && bun run test",
|
|
33
33
|
"prepublishOnly": "bun run check",
|
|
34
34
|
"lint": "biome check .",
|
|
35
|
-
"prepare": "lefthook install && prisma generate"
|
|
35
|
+
"prepare": "if git rev-parse --git-dir >/dev/null 2>&1; then lefthook install; fi && prisma generate"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
+
"@inixiative/signet": "^0.2.1",
|
|
38
39
|
"@prisma/client": "^6.6.0",
|
|
39
40
|
"js-tiktoken": "1.0.21",
|
|
40
41
|
"prisma": "^6.6.0",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@biomejs/biome": "2.5.2",
|
|
45
|
-
"@inixiative/config": "^0.5.
|
|
46
|
+
"@inixiative/config": "^0.5.55",
|
|
46
47
|
"@types/bun": "1.4.2",
|
|
47
48
|
"lefthook": "2.1.9",
|
|
48
49
|
"typescript": "6.0.3"
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
4
5
|
import { join, resolve } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { parseArgs } from 'node:util';
|
|
@@ -15,16 +16,11 @@ import {
|
|
|
15
16
|
writeAgents,
|
|
16
17
|
} from './agents';
|
|
17
18
|
import { importChatGPTThread } from './chatgpt';
|
|
18
|
-
import {
|
|
19
|
-
archiveRequest,
|
|
20
|
-
kingdomFields,
|
|
21
|
-
routingPreview,
|
|
22
|
-
searchRemotes,
|
|
23
|
-
syncArchives,
|
|
24
|
-
} from './client';
|
|
19
|
+
import { archiveRequest, routingPreview, searchRemotes, syncArchives } from './client';
|
|
25
20
|
import { collectSessions } from './collector';
|
|
26
21
|
import { archiveDestinationSchema, connectDestination, readDestinations } from './config';
|
|
27
22
|
import { importTranscriptFile } from './import-file';
|
|
23
|
+
import { pairWithKingdom, verifyKingdomDestination } from './kingdom';
|
|
28
24
|
import { previewImports } from './preview';
|
|
29
25
|
import { ArchiveClient, DEFAULT_URL } from './remote';
|
|
30
26
|
import { startArchiveServer } from './server';
|
|
@@ -50,10 +46,10 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
50
46
|
url: { type: 'string' },
|
|
51
47
|
kind: { type: 'string', default: 'archive' },
|
|
52
48
|
'project-id': { type: 'string' },
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
|
|
49
|
+
'integration-id': { type: 'string' },
|
|
50
|
+
'resource-id': { type: 'string' },
|
|
51
|
+
'credential-file': { type: 'string' },
|
|
52
|
+
kingdom: { type: 'string' },
|
|
57
53
|
'token-env': { type: 'string' },
|
|
58
54
|
'token-file': { type: 'string' },
|
|
59
55
|
file: { type: 'string' },
|
|
@@ -75,6 +71,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
75
71
|
watch: { type: 'boolean', default: false },
|
|
76
72
|
sync: { type: 'boolean', default: false },
|
|
77
73
|
remote: { type: 'boolean', default: false },
|
|
74
|
+
yes: { type: 'boolean', default: false },
|
|
78
75
|
help: { type: 'boolean' },
|
|
79
76
|
},
|
|
80
77
|
});
|
|
@@ -91,7 +88,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
91
88
|
'import --file PATH --source codex|claude-code|chatgpt --project-id ID [--tag TAG]\n' +
|
|
92
89
|
'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
|
|
93
90
|
'list | export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...] | search --query TEXT [--remote]\n' +
|
|
94
|
-
'
|
|
91
|
+
'pair --kingdom KINGDOM_API_URL [--name NAME] [--yes] (register this Archive with Kingdom; claim the review code there, then confirm the owner)\n' +
|
|
92
|
+
'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) (a remote Archive, for serve --sync)\n' +
|
|
93
|
+
'connect --kind kingdom --url KINGDOM_API_URL --credential-file PATH --integration-id UUID --resource-id UUID --project-id ID (a hosted Archive through Kingdom)\n' +
|
|
95
94
|
'sync | routes (DATABASE_URL: publish to, or preview, destinations once)\n' +
|
|
96
95
|
'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
|
|
97
96
|
'agents remove-collector --name N | server --url URL | install | uninstall | status\n' +
|
|
@@ -162,8 +161,8 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
162
161
|
while (v.sync && !stopped) {
|
|
163
162
|
try {
|
|
164
163
|
const results = await syncArchives(instance.store, readDestinations(config));
|
|
165
|
-
const failed = results.filter((r) => r.status.startsWith('failed'))
|
|
166
|
-
if (failed) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
|
|
164
|
+
const failed = results.filter((r) => r.status.startsWith('failed'));
|
|
165
|
+
if (failed.length) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
|
|
167
166
|
} catch {
|
|
168
167
|
console.error(JSON.stringify({ sync: 'unavailable' }));
|
|
169
168
|
}
|
|
@@ -180,34 +179,61 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
180
179
|
process.once('SIGINT', stop);
|
|
181
180
|
return;
|
|
182
181
|
}
|
|
182
|
+
if (command === 'pair') {
|
|
183
|
+
if (!v.kingdom) throw new Error('pair requires --kingdom KINGDOM_API_URL');
|
|
184
|
+
const local = new ArchiveClient({
|
|
185
|
+
url: v.url ?? DEFAULT_URL,
|
|
186
|
+
token: process.env.ARCHIVE_TOKEN ?? readToken() ?? '',
|
|
187
|
+
});
|
|
188
|
+
output(
|
|
189
|
+
await pairWithKingdom({
|
|
190
|
+
kingdom: v.kingdom,
|
|
191
|
+
name: v.name ?? `${hostname()} Archive`,
|
|
192
|
+
sourceId: await local.sourceId(),
|
|
193
|
+
directory: join(home, 'kingdom'),
|
|
194
|
+
onReview: (review) => console.error(JSON.stringify(review)),
|
|
195
|
+
confirmOwner: async ({ ownerName }) => {
|
|
196
|
+
if (v.yes) return true;
|
|
197
|
+
if (!process.stdin.isTTY)
|
|
198
|
+
throw new Error(`Registered with ${ownerName}; rerun with --yes to accept it`);
|
|
199
|
+
const answer = prompt(`Kingdom registered this Archive with ${ownerName}. Accept? [y/N]`);
|
|
200
|
+
return /^y(es)?$/i.test(answer?.trim() ?? '');
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
183
206
|
if (command === 'connect' || command === 'setup') {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
projectId: v['project-id'],
|
|
187
|
-
url: v.url,
|
|
188
|
-
tokenEnv: v['token-env'] ?? (destinationTokenFile ? undefined : 'ARCHIVE_TOKEN'),
|
|
189
|
-
tokenFile: destinationTokenFile,
|
|
190
|
-
kind: v.kind,
|
|
191
|
-
...(v.kind === 'kingdom'
|
|
207
|
+
const destination = archiveDestinationSchema.parse(
|
|
208
|
+
v.kind === 'kingdom'
|
|
192
209
|
? {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
210
|
+
kind: 'kingdom',
|
|
211
|
+
projectId: v['project-id'],
|
|
212
|
+
url: v.url,
|
|
213
|
+
credentialFile: v['credential-file'] && resolve(v['credential-file']),
|
|
214
|
+
integrationId: v['integration-id'],
|
|
215
|
+
resourceId: v['resource-id'],
|
|
197
216
|
}
|
|
198
|
-
: {
|
|
199
|
-
|
|
217
|
+
: {
|
|
218
|
+
kind: 'archive',
|
|
219
|
+
projectId: v['project-id'],
|
|
220
|
+
url: v.url,
|
|
221
|
+
tokenEnv: v['token-env'] ?? (v['token-file'] ? undefined : 'ARCHIVE_TOKEN'),
|
|
222
|
+
tokenFile: v['token-file'] && resolve(v['token-file']),
|
|
223
|
+
},
|
|
224
|
+
);
|
|
200
225
|
// Verify credentials and protocol before saving; no records uploaded by setup.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
226
|
+
if (destination.kind === 'kingdom') await verifyKingdomDestination(destination);
|
|
227
|
+
else {
|
|
228
|
+
const probe = await archiveRequest(destination, 'search', {
|
|
229
|
+
query: '',
|
|
230
|
+
budget: 16,
|
|
231
|
+
limit: 1,
|
|
232
|
+
projectId: destination.projectId,
|
|
233
|
+
});
|
|
234
|
+
if (!Array.isArray(probe.data?.archives))
|
|
235
|
+
throw new Error('Destination is not an Archive-compatible endpoint');
|
|
236
|
+
}
|
|
211
237
|
output(connectDestination(config, destination));
|
|
212
238
|
return;
|
|
213
239
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kingdomUrl, SignetClient } from '@inixiative/signet';
|
|
1
2
|
import {
|
|
2
3
|
type ArchiveDestination,
|
|
3
4
|
archiveDestinationSchema,
|
|
@@ -9,33 +10,25 @@ import type { ArchiveStore } from './store';
|
|
|
9
10
|
|
|
10
11
|
export { type ArchiveDestination, archiveDestinationSchema } from './config';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Object.entries({ ownerModel, organizationId, spaceId, connectionId }).filter(
|
|
18
|
-
([, value]) => value !== undefined,
|
|
19
|
-
),
|
|
20
|
-
);
|
|
21
|
-
}
|
|
13
|
+
type DirectDestination = Extract<ArchiveDestination, { kind: 'archive' }>;
|
|
14
|
+
type KingdomDestination = Extract<ArchiveDestination, { kind: 'kingdom' }>;
|
|
15
|
+
type Ingested = { id: string; digest: string; revision: number; changed: boolean };
|
|
16
|
+
|
|
17
|
+
const sessionWriteTimeoutMs = 300_000;
|
|
22
18
|
|
|
23
19
|
export async function archiveRequest(
|
|
24
|
-
destination:
|
|
20
|
+
destination: DirectDestination,
|
|
25
21
|
action: string,
|
|
26
22
|
body: unknown,
|
|
27
23
|
transport: typeof fetch = fetch,
|
|
28
24
|
) {
|
|
29
25
|
const url = destinationUrl(destination.url);
|
|
30
26
|
const token = destinationToken(destination);
|
|
31
|
-
if (!token
|
|
32
|
-
|
|
33
|
-
const path =
|
|
34
|
-
destination.kind === 'kingdom' && destination.connectionId ? `remote/${action}` : action;
|
|
35
|
-
const response = await transport(new URL(`api/v1/archive/${path}`, url), {
|
|
27
|
+
if (!token) throw new Error('Archive server token unavailable');
|
|
28
|
+
const response = await transport(new URL(`api/v1/archive/${action}`, url), {
|
|
36
29
|
method: 'POST',
|
|
37
30
|
redirect: 'error',
|
|
38
|
-
signal: AbortSignal.timeout(30_000),
|
|
31
|
+
signal: AbortSignal.timeout(action === 'ingest' ? sessionWriteTimeoutMs : 30_000),
|
|
39
32
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
40
33
|
body: JSON.stringify(body),
|
|
41
34
|
});
|
|
@@ -44,6 +37,39 @@ export async function archiveRequest(
|
|
|
44
37
|
return response.json() as Promise<{ data: any }>;
|
|
45
38
|
}
|
|
46
39
|
|
|
40
|
+
const kingdomSignet = async (destination: KingdomDestination) => {
|
|
41
|
+
const signet = await SignetClient.fromFile(destination.credentialFile);
|
|
42
|
+
if (signet.url !== kingdomUrl(destination.url))
|
|
43
|
+
throw new Error('The Signet credential belongs to a different Kingdom');
|
|
44
|
+
return signet;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Runs one Archive operation on the destination's library through the paired Signet. */
|
|
48
|
+
export async function kingdomArchiveOperation(
|
|
49
|
+
destination: KingdomDestination,
|
|
50
|
+
operation: 'sessions.write' | 'documents.search',
|
|
51
|
+
input: Record<string, unknown>,
|
|
52
|
+
) {
|
|
53
|
+
const { result } = await (await kingdomSignet(destination)).execute(
|
|
54
|
+
{
|
|
55
|
+
integrationId: destination.integrationId,
|
|
56
|
+
operation,
|
|
57
|
+
input: { resourceId: destination.resourceId, limit: 20, ...input },
|
|
58
|
+
},
|
|
59
|
+
{ timeoutMs: operation === 'sessions.write' ? sessionWriteTimeoutMs : 30_000 },
|
|
60
|
+
);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ingest = async (
|
|
65
|
+
destination: ArchiveDestination,
|
|
66
|
+
body: { snapshot: unknown; previousDigest: string | null },
|
|
67
|
+
transport: typeof fetch,
|
|
68
|
+
) =>
|
|
69
|
+
(destination.kind === 'archive'
|
|
70
|
+
? (await archiveRequest(destination, 'ingest', body, transport)).data
|
|
71
|
+
: await kingdomArchiveOperation(destination, 'sessions.write', body)) as Ingested | undefined;
|
|
72
|
+
|
|
47
73
|
export async function publishArchive(
|
|
48
74
|
store: ArchiveStore,
|
|
49
75
|
id: string,
|
|
@@ -68,17 +94,12 @@ export async function publishArchive(
|
|
|
68
94
|
const queued = await store.read(id, pending.revision);
|
|
69
95
|
if (!queued || queued.snapshot.projectId !== destination.projectId)
|
|
70
96
|
throw new Error('Pending archive is outside destination project');
|
|
71
|
-
const
|
|
97
|
+
const acknowledged = await ingest(
|
|
72
98
|
destination,
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
...kingdomFields(destination),
|
|
76
|
-
previousDigest,
|
|
77
|
-
snapshot: queued.snapshot,
|
|
78
|
-
},
|
|
99
|
+
{ previousDigest, snapshot: queued.snapshot },
|
|
79
100
|
transport,
|
|
80
101
|
);
|
|
81
|
-
if (
|
|
102
|
+
if (acknowledged?.digest !== queued.digest) throw new Error('Archive acknowledgement mismatch');
|
|
82
103
|
await store.delivered(id, receiptKey, queued.digest);
|
|
83
104
|
sent = true;
|
|
84
105
|
}
|
|
@@ -96,7 +117,7 @@ export async function routingPreview(store: ArchiveStore, destinations: ArchiveD
|
|
|
96
117
|
.map((d) => ({
|
|
97
118
|
kind: d.kind,
|
|
98
119
|
url: d.url,
|
|
99
|
-
...
|
|
120
|
+
...(d.kind === 'kingdom' ? { integrationId: d.integrationId } : {}),
|
|
100
121
|
})),
|
|
101
122
|
}));
|
|
102
123
|
}
|
|
@@ -112,11 +133,11 @@ export async function syncArchives(store: ArchiveStore, destinations: ArchiveDes
|
|
|
112
133
|
destination: destination.url,
|
|
113
134
|
status: result.unchanged ? 'unchanged' : 'published',
|
|
114
135
|
});
|
|
115
|
-
} catch {
|
|
136
|
+
} catch (error) {
|
|
116
137
|
results.push({
|
|
117
138
|
id: archive.id,
|
|
118
139
|
destination: destination.url,
|
|
119
|
-
status:
|
|
140
|
+
status: `failed; local revision retained: ${error instanceof Error ? error.message : String(error)}`,
|
|
120
141
|
});
|
|
121
142
|
}
|
|
122
143
|
}
|
|
@@ -131,17 +152,24 @@ export async function searchRemotes(
|
|
|
131
152
|
return Promise.all(
|
|
132
153
|
destinations.map(async (destination) => {
|
|
133
154
|
try {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
const data =
|
|
156
|
+
destination.kind === 'archive'
|
|
157
|
+
? (
|
|
158
|
+
await archiveRequest(destination, 'search', {
|
|
159
|
+
query,
|
|
160
|
+
budget,
|
|
161
|
+
projectId: destination.projectId,
|
|
162
|
+
})
|
|
163
|
+
).data
|
|
164
|
+
: await kingdomArchiveOperation(destination, 'documents.search', {
|
|
165
|
+
query,
|
|
166
|
+
budget,
|
|
167
|
+
projectId: destination.projectId,
|
|
168
|
+
});
|
|
141
169
|
return {
|
|
142
170
|
destination: destination.url,
|
|
143
171
|
projectId: destination.projectId,
|
|
144
|
-
data
|
|
172
|
+
data,
|
|
145
173
|
};
|
|
146
174
|
} catch {
|
|
147
175
|
return {
|
package/src/config.ts
CHANGED
|
@@ -14,43 +14,36 @@ import {
|
|
|
14
14
|
import { dirname, isAbsolute } from 'node:path';
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
spaceId: z.uuid().optional(),
|
|
31
|
-
};
|
|
32
|
-
export const archiveDestinationSchema = z
|
|
33
|
-
.union([
|
|
34
|
-
z.strictObject({
|
|
35
|
-
...common,
|
|
36
|
-
kind: z.literal('archive'),
|
|
37
|
-
}),
|
|
38
|
-
z.strictObject({
|
|
39
|
-
...common,
|
|
40
|
-
kind: z.literal('kingdom'),
|
|
41
|
-
/** A hosted Archive that Kingdom forwards to; omitted, Kingdom stores the archive itself. */
|
|
42
|
-
connectionId: z
|
|
43
|
-
.string()
|
|
44
|
-
.regex(/^[a-z0-9-]+$/)
|
|
45
|
-
.max(120)
|
|
46
|
-
.optional(),
|
|
47
|
-
...kingdomOwnerFields,
|
|
48
|
-
}),
|
|
49
|
-
])
|
|
17
|
+
const projectId = z.string().min(1).max(256);
|
|
18
|
+
/** A remote Archive this one syncs to directly with its server token. */
|
|
19
|
+
const archiveDestination = z
|
|
20
|
+
.strictObject({
|
|
21
|
+
kind: z.literal('archive'),
|
|
22
|
+
projectId,
|
|
23
|
+
url: z.url(),
|
|
24
|
+
tokenEnv: z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(/^[A-Z][A-Z0-9_]+$/)
|
|
27
|
+
.optional(),
|
|
28
|
+
tokenFile: z.string().max(4096).refine(isAbsolute, 'tokenFile must be absolute').optional(),
|
|
29
|
+
})
|
|
50
30
|
.refine(
|
|
51
31
|
(d) => Boolean(d.tokenEnv) !== Boolean(d.tokenFile),
|
|
52
32
|
'Set exactly one of tokenEnv or tokenFile',
|
|
53
33
|
);
|
|
34
|
+
/**
|
|
35
|
+
* A hosted Archive reached through Kingdom: this Archive presents the Signet it was paired with,
|
|
36
|
+
* granting sessions.write on that Archive's library.
|
|
37
|
+
*/
|
|
38
|
+
const kingdomDestination = z.strictObject({
|
|
39
|
+
kind: z.literal('kingdom'),
|
|
40
|
+
projectId,
|
|
41
|
+
url: z.url(),
|
|
42
|
+
credentialFile: z.string().max(4096).refine(isAbsolute, 'credentialFile must be absolute'),
|
|
43
|
+
integrationId: z.uuid(),
|
|
44
|
+
resourceId: z.uuid(),
|
|
45
|
+
});
|
|
46
|
+
export const archiveDestinationSchema = z.union([archiveDestination, kingdomDestination]);
|
|
54
47
|
export type ArchiveDestination = z.infer<typeof archiveDestinationSchema>;
|
|
55
48
|
|
|
56
49
|
/** Private regular file owned by this user; a symlink or group/world access is refused. */
|
|
@@ -65,7 +58,7 @@ export function readTokenFile(path: string) {
|
|
|
65
58
|
closeSync(fd);
|
|
66
59
|
}
|
|
67
60
|
}
|
|
68
|
-
export const destinationToken = (destination:
|
|
61
|
+
export const destinationToken = (destination: z.infer<typeof archiveDestination>) =>
|
|
69
62
|
destination.tokenFile ? readTokenFile(destination.tokenFile) : process.env[destination.tokenEnv!];
|
|
70
63
|
|
|
71
64
|
export function destinationUrl(input: string) {
|
|
@@ -93,18 +86,12 @@ export function readDestinations(file: string): ArchiveDestination[] {
|
|
|
93
86
|
}
|
|
94
87
|
export function destinationIdentity(destination: ArchiveDestination) {
|
|
95
88
|
const url = destinationUrl(destination.url);
|
|
96
|
-
// Preserve existing Foundry/Kingdom upload receipt identities.
|
|
97
89
|
return JSON.stringify([
|
|
98
90
|
url.origin,
|
|
99
91
|
new URL(destination.url).pathname,
|
|
100
92
|
destination.kind === 'archive'
|
|
101
93
|
? 'standalone'
|
|
102
|
-
: [
|
|
103
|
-
destination.connectionId ?? 'kingdom',
|
|
104
|
-
destination.ownerModel ?? null,
|
|
105
|
-
destination.organizationId ?? null,
|
|
106
|
-
destination.spaceId ?? null,
|
|
107
|
-
],
|
|
94
|
+
: ['kingdom', destination.integrationId, destination.resourceId],
|
|
108
95
|
]);
|
|
109
96
|
}
|
|
110
97
|
export function connectDestination(file: string, input: unknown) {
|
|
@@ -127,8 +114,10 @@ export function connectDestination(file: string, input: unknown) {
|
|
|
127
114
|
kind: destination.kind,
|
|
128
115
|
projectId: destination.projectId,
|
|
129
116
|
url: destination.url,
|
|
130
|
-
...(destination.
|
|
131
|
-
? {
|
|
132
|
-
:
|
|
117
|
+
...(destination.kind === 'kingdom'
|
|
118
|
+
? { integrationId: destination.integrationId, resourceId: destination.resourceId }
|
|
119
|
+
: destination.tokenFile
|
|
120
|
+
? { tokenFile: destination.tokenFile }
|
|
121
|
+
: { tokenEnv: destination.tokenEnv }),
|
|
133
122
|
};
|
|
134
123
|
}
|
package/src/kingdom.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
collectSignet,
|
|
5
|
+
generateSignetKey,
|
|
6
|
+
installationInquiries,
|
|
7
|
+
kingdomUrl,
|
|
8
|
+
registerInstallation,
|
|
9
|
+
requestRegistration,
|
|
10
|
+
SignetClient,
|
|
11
|
+
saveCollectedSignet,
|
|
12
|
+
writePrivateJson,
|
|
13
|
+
} from '@inixiative/signet';
|
|
14
|
+
import type { ArchiveDestination } from './config';
|
|
15
|
+
|
|
16
|
+
const pollMs = 3000;
|
|
17
|
+
|
|
18
|
+
/** Libraries this Archive's Signet may write to, as Kingdom describes them. */
|
|
19
|
+
export async function writableLibraries(credentialFile: string) {
|
|
20
|
+
const description = await (await SignetClient.fromFile(credentialFile)).describe();
|
|
21
|
+
return (
|
|
22
|
+
description.operations
|
|
23
|
+
.find((operation) => operation.key === 'sessions.write')
|
|
24
|
+
?.resources.map((resource) => ({
|
|
25
|
+
integrationId: resource.integrationId,
|
|
26
|
+
resourceId: resource.id,
|
|
27
|
+
name: resource.name,
|
|
28
|
+
})) ?? []
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Pairing = {
|
|
33
|
+
kingdom: string;
|
|
34
|
+
name: string;
|
|
35
|
+
sourceId: string;
|
|
36
|
+
directory: string;
|
|
37
|
+
onReview: (review: { reviewCode: string; review: string; expiresAt: string }) => void;
|
|
38
|
+
confirmOwner: (owner: { ownerName: string | null; owner: unknown }) => Promise<boolean>;
|
|
39
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The key that names this Archive to one Kingdom; reused for every owner it registers with. */
|
|
43
|
+
async function installationKey(directory: string) {
|
|
44
|
+
const keyFile = join(directory, 'installation-key.json');
|
|
45
|
+
if (!existsSync(keyFile)) await writePrivateJson(keyFile, generateSignetKey());
|
|
46
|
+
return keyFile;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Registers this Archive with Kingdom as an Installation and asks to become an owner's local
|
|
51
|
+
* Archive integration. A person claims the review code in Kingdom; once approved, the Archive
|
|
52
|
+
* confirms the owner locally before collecting its Signet.
|
|
53
|
+
*/
|
|
54
|
+
export async function pairWithKingdom(input: Pairing) {
|
|
55
|
+
const kingdom = kingdomUrl(input.kingdom);
|
|
56
|
+
const directory = join(input.directory, new URL(kingdom).host);
|
|
57
|
+
const keyFile = await installationKey(directory);
|
|
58
|
+
await registerInstallation(kingdom, keyFile, {
|
|
59
|
+
kind: 'archive',
|
|
60
|
+
name: input.name,
|
|
61
|
+
sourceId: input.sourceId,
|
|
62
|
+
});
|
|
63
|
+
const askedAt = Date.now();
|
|
64
|
+
const pending = await requestRegistration(kingdom, keyFile, {
|
|
65
|
+
name: input.name,
|
|
66
|
+
lifecycle: 'ongoing',
|
|
67
|
+
resources: [],
|
|
68
|
+
expiresAt: null,
|
|
69
|
+
maxRequests: null,
|
|
70
|
+
maxConcurrent: 4,
|
|
71
|
+
});
|
|
72
|
+
input.onReview({
|
|
73
|
+
reviewCode: pending.reviewCode,
|
|
74
|
+
review: `${kingdom}/dashboard?reviewSignet=${pending.reviewCode}`,
|
|
75
|
+
expiresAt: pending.expiresAt,
|
|
76
|
+
});
|
|
77
|
+
const sleep = input.sleep ?? Bun.sleep;
|
|
78
|
+
for (;;) {
|
|
79
|
+
const state = await installationInquiries(kingdom, keyFile);
|
|
80
|
+
if (state.declinedAt && Date.parse(state.declinedAt) >= askedAt)
|
|
81
|
+
throw new Error('The registration was declined in Kingdom');
|
|
82
|
+
const inquiry = state.inquiries.find(
|
|
83
|
+
(item) => item.type === 'registerIntegration' && Date.parse(item.createdAt) >= askedAt,
|
|
84
|
+
);
|
|
85
|
+
if (inquiry?.status === 'approved') {
|
|
86
|
+
if (!(await input.confirmOwner({ ownerName: inquiry.ownerName, owner: inquiry.owner })))
|
|
87
|
+
throw new Error('Not collected: the owner was not confirmed');
|
|
88
|
+
const collected = await collectSignet(kingdom, keyFile, inquiry.id);
|
|
89
|
+
if (!collected) throw new Error('Kingdom has not released the Signet yet; try again');
|
|
90
|
+
const credentialFile = join(directory, `signet-${collected.signetId}.json`);
|
|
91
|
+
await saveCollectedSignet(credentialFile, kingdom, keyFile, collected);
|
|
92
|
+
return {
|
|
93
|
+
paired: true,
|
|
94
|
+
owner: inquiry.ownerName,
|
|
95
|
+
integrationId: collected.integrationId,
|
|
96
|
+
credentialFile,
|
|
97
|
+
libraries: await writableLibraries(credentialFile),
|
|
98
|
+
next: 'archive connect --kind kingdom --url KINGDOM --credential-file FILE --integration-id ID --resource-id ID --project-id PROJECT',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (inquiry && inquiry.status !== 'sent')
|
|
102
|
+
throw new Error(`The registration was ${inquiry.status} in Kingdom`);
|
|
103
|
+
if (!inquiry && !state.pending)
|
|
104
|
+
throw new Error('The review code expired before it was claimed; run archive pair again');
|
|
105
|
+
await sleep(pollMs);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function verifyKingdomDestination(
|
|
110
|
+
destination: Extract<ArchiveDestination, { kind: 'kingdom' }>,
|
|
111
|
+
) {
|
|
112
|
+
const libraries = await writableLibraries(destination.credentialFile);
|
|
113
|
+
if (
|
|
114
|
+
!libraries.some(
|
|
115
|
+
(library) =>
|
|
116
|
+
library.integrationId === destination.integrationId &&
|
|
117
|
+
library.resourceId === destination.resourceId,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
throw new Error("This Archive's Signet does not grant sessions.write on that library");
|
|
121
|
+
}
|
package/src/remote.ts
CHANGED
|
@@ -32,13 +32,17 @@ export class ArchiveRequestError extends Error {
|
|
|
32
32
|
export class ArchiveClient {
|
|
33
33
|
private cachedSourceId?: string;
|
|
34
34
|
constructor(private readonly options: { url: string; token: string; fetch?: typeof fetch }) {}
|
|
35
|
-
async request<T>(
|
|
35
|
+
async request<T>(
|
|
36
|
+
action: string,
|
|
37
|
+
body: unknown = {},
|
|
38
|
+
{ timeoutMs = 30_000 }: { timeoutMs?: number } = {},
|
|
39
|
+
): Promise<T> {
|
|
36
40
|
const response = await (this.options.fetch ?? fetch)(
|
|
37
41
|
new URL(`api/v1/archive/${action}`, this.options.url),
|
|
38
42
|
{
|
|
39
43
|
method: 'POST',
|
|
40
44
|
redirect: 'error',
|
|
41
|
-
signal: AbortSignal.timeout(
|
|
45
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
42
46
|
headers: {
|
|
43
47
|
authorization: `Bearer ${this.options.token}`,
|
|
44
48
|
'content-type': 'application/json',
|