@inixiative/archive 0.7.0 → 0.8.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 CHANGED
@@ -68,19 +68,33 @@ archive agents status
68
68
 
69
69
  ## Connect through Kingdom
70
70
 
71
- Pair a runtime with Kingdom (Foundry: Settings → Kingdom) and expose its `kingdom_runtime_` credential in an environment variable, then either let Kingdom store the archives:
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 connect --kind kingdom --url https://your-kingdom.example --project-id inixiative --token-env KINGDOM_ARCHIVE_TOKEN
76
+ bun run archive pair --kingdom https://api.your-kingdom.example --name "Work laptop"
75
77
  ```
76
78
 
77
- or forward them through Kingdom to a hosted Archive it has bound for that project:
79
+ `pair` creates a device key in `<home>/kingdom/` and asks Kingdom to pair this Archive, identified by its `sourceId`. It prints a review code and a link. Open the link, choose the owner and the hosted Archives this machine may write to, and approve. The command waits for the approval, then saves the delivered Signet credential (0600) and lists the libraries it may write to.
80
+
81
+ Next, route a project to one of those libraries:
78
82
 
79
83
  ```sh
80
- bun run archive connect --kind kingdom --url https://your-kingdom.example --connection-id inixiative --project-id inixiative --token-env KINGDOM_ARCHIVE_TOKEN
84
+ 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
85
  ```
82
86
 
83
- Kingdom takes the owner from the runtime credential; `--owner-model`, `--organization-id` and `--space-id` narrow it to an organization or space that owner manages. A forwarding connection accepts only its bound `projectId`, and the hosted Archive's own token stays in Kingdom's server environment. A Kingdom runtime credential is distinct from a standalone Archive token. Kingdom retains responsibility for memberships, shares, revocation and hosted browsing.
87
+ `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).
88
+
89
+ Kingdom enforces the grant on every write:
90
+
91
+ - It accepts only snapshots whose `sourceId` is this Archive's.
92
+ - It records the paired integration on the session's actor.
93
+ - It resolves a revision conflict from this source by writing over the hosted head. Older captures are still refused.
94
+
95
+ 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.
96
+
97
+ 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
98
 
85
99
  Foundry writes its captures to the local Archive server through `@inixiative/archive/remote`.
86
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/archive",
3
- "version": "0.7.0",
3
+ "version": "0.8.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.1.0",
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.44",
46
+ "@inixiative/config": "^0.5.53",
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
- 'connection-id': { type: 'string' },
54
- 'owner-model': { type: 'string' },
55
- 'organization-id': { type: 'string' },
56
- 'space-id': { type: 'string' },
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' },
@@ -91,7 +87,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
91
87
  'import --file PATH --source codex|claude-code|chatgpt --project-id ID [--tag TAG]\n' +
92
88
  'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
93
89
  'list | export --id ID | tag --id ID [--tag TAG ...] [--untag TAG ...] | search --query TEXT [--remote]\n' +
94
- 'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) [--kind kingdom ...] (destinations for serve --sync)\n' +
90
+ 'pair --kingdom KINGDOM_API_URL [--name NAME] (pair this Archive with Kingdom; approve the review code there)\n' +
91
+ 'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) (a remote Archive, for serve --sync)\n' +
92
+ '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
93
  'sync | routes (DATABASE_URL: publish to, or preview, destinations once)\n' +
96
94
  'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
97
95
  'agents remove-collector --name N | server --url URL | install | uninstall | status\n' +
@@ -162,8 +160,8 @@ export async function runCli(args = Bun.argv.slice(2)) {
162
160
  while (v.sync && !stopped) {
163
161
  try {
164
162
  const results = await syncArchives(instance.store, readDestinations(config));
165
- const failed = results.filter((r) => r.status.startsWith('failed')).length;
166
- if (failed) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
163
+ const failed = results.filter((r) => r.status.startsWith('failed'));
164
+ if (failed.length) console.error(JSON.stringify({ sync: 'failed', archives: failed }));
167
165
  } catch {
168
166
  console.error(JSON.stringify({ sync: 'unavailable' }));
169
167
  }
@@ -180,34 +178,54 @@ export async function runCli(args = Bun.argv.slice(2)) {
180
178
  process.once('SIGINT', stop);
181
179
  return;
182
180
  }
181
+ if (command === 'pair') {
182
+ if (!v.kingdom) throw new Error('pair requires --kingdom KINGDOM_API_URL');
183
+ const local = new ArchiveClient({
184
+ url: v.url ?? DEFAULT_URL,
185
+ token: process.env.ARCHIVE_TOKEN ?? readToken() ?? '',
186
+ });
187
+ output(
188
+ await pairWithKingdom({
189
+ kingdom: v.kingdom,
190
+ name: v.name ?? `${hostname()} Archive`,
191
+ sourceId: await local.sourceId(),
192
+ directory: join(home, 'kingdom'),
193
+ onReview: (review) => console.error(JSON.stringify(review)),
194
+ }),
195
+ );
196
+ return;
197
+ }
183
198
  if (command === 'connect' || command === 'setup') {
184
- const destinationTokenFile = v['token-file'] && resolve(v['token-file']);
185
- const destination = archiveDestinationSchema.parse({
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'
199
+ const destination = archiveDestinationSchema.parse(
200
+ v.kind === 'kingdom'
192
201
  ? {
193
- connectionId: v['connection-id'],
194
- ownerModel: v['owner-model'],
195
- organizationId: v['organization-id'],
196
- spaceId: v['space-id'],
202
+ kind: 'kingdom',
203
+ projectId: v['project-id'],
204
+ url: v.url,
205
+ credentialFile: v['credential-file'] && resolve(v['credential-file']),
206
+ integrationId: v['integration-id'],
207
+ resourceId: v['resource-id'],
197
208
  }
198
- : {}),
199
- });
209
+ : {
210
+ kind: 'archive',
211
+ projectId: v['project-id'],
212
+ url: v.url,
213
+ tokenEnv: v['token-env'] ?? (v['token-file'] ? undefined : 'ARCHIVE_TOKEN'),
214
+ tokenFile: v['token-file'] && resolve(v['token-file']),
215
+ },
216
+ );
200
217
  // Verify credentials and protocol before saving; no records uploaded by setup.
201
- const probe = await archiveRequest(destination, 'search', {
202
- query: '',
203
- budget: 16,
204
- limit: 1,
205
- ...(destination.kind === 'archive'
206
- ? { projectId: destination.projectId }
207
- : kingdomFields(destination)),
208
- });
209
- if (!Array.isArray(probe.data?.archives))
210
- throw new Error('Destination is not an Archive-compatible endpoint');
218
+ if (destination.kind === 'kingdom') await verifyKingdomDestination(destination);
219
+ else {
220
+ const probe = await archiveRequest(destination, 'search', {
221
+ query: '',
222
+ budget: 16,
223
+ limit: 1,
224
+ projectId: destination.projectId,
225
+ });
226
+ if (!Array.isArray(probe.data?.archives))
227
+ throw new Error('Destination is not an Archive-compatible endpoint');
228
+ }
211
229
  output(connectDestination(config, destination));
212
230
  return;
213
231
  }
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
- /** Identifies the Kingdom owner and forwarding connection; standalone Archive needs neither. */
13
- export function kingdomFields(destination: ArchiveDestination) {
14
- if (destination.kind === 'archive') return {};
15
- const { ownerModel, organizationId, spaceId, connectionId } = destination;
16
- return Object.fromEntries(
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: ArchiveDestination,
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 || (destination.kind === 'kingdom' && !token.startsWith('kingdom_runtime_')))
32
- throw new Error('Archive runtime credential unavailable');
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 body = await archiveRequest(
97
+ const acknowledged = await ingest(
72
98
  destination,
73
- 'ingest',
74
- {
75
- ...kingdomFields(destination),
76
- previousDigest,
77
- snapshot: queued.snapshot,
78
- },
99
+ { previousDigest, snapshot: queued.snapshot },
79
100
  transport,
80
101
  );
81
- if (body.data?.digest !== queued.digest) throw new Error('Archive acknowledgement mismatch');
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
- ...kingdomFields(d),
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: 'failed; local revision retained',
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 result = await archiveRequest(destination, 'search', {
135
- query,
136
- budget,
137
- ...(destination.kind === 'archive'
138
- ? { projectId: destination.projectId }
139
- : kingdomFields(destination)),
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: result.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 common = {
18
- projectId: z.string().min(1).max(256),
19
- url: z.url(),
20
- tokenEnv: z
21
- .string()
22
- .regex(/^[A-Z][A-Z0-9_]+$/)
23
- .optional(),
24
- tokenFile: z.string().max(4096).refine(isAbsolute, 'tokenFile must be absolute').optional(),
25
- };
26
- /** Kingdom derives the owner from the runtime credential; these narrow it to a space or organization it manages. */
27
- export const kingdomOwnerFields = {
28
- ownerModel: z.enum(['User', 'OrganizationUser', 'Organization', 'Space', 'SpaceUser']).optional(),
29
- organizationId: z.uuid().optional(),
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: ArchiveDestination) =>
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.tokenFile
131
- ? { tokenFile: destination.tokenFile }
132
- : { tokenEnv: destination.tokenEnv }),
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,103 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import {
4
+ collectPairing,
5
+ generateSignetKey,
6
+ kingdomUrl,
7
+ requestPairing,
8
+ SignetClient,
9
+ saveCollectedSignet,
10
+ writePrivateJson,
11
+ } from '@inixiative/signet';
12
+ import type { ArchiveDestination } from './config';
13
+
14
+ const pollMs = 3000;
15
+
16
+ /** Libraries this Archive's Signet may write to, as Kingdom describes them. */
17
+ export async function writableLibraries(credentialFile: string) {
18
+ const description = await (await SignetClient.fromFile(credentialFile)).describe();
19
+ return (
20
+ description.operations
21
+ .find((operation) => operation.key === 'sessions.write')
22
+ ?.resources.map((resource) => ({
23
+ integrationId: resource.integrationId,
24
+ resourceId: resource.id,
25
+ name: resource.name,
26
+ })) ?? []
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Pairs this Archive with Kingdom as a local Archive integration bound to its sourceId. The owner
32
+ * approves the review code in Kingdom and chooses which hosted Archives it may write to.
33
+ */
34
+ export async function pairWithKingdom(input: {
35
+ kingdom: string;
36
+ name: string;
37
+ sourceId: string;
38
+ directory: string;
39
+ onReview: (review: { reviewCode: string; review: string; expiresAt: string }) => void;
40
+ sleep?: (ms: number) => Promise<unknown>;
41
+ }) {
42
+ const kingdom = kingdomUrl(input.kingdom);
43
+ const keyFile = join(input.directory, `key-${crypto.randomUUID()}.json`);
44
+ await writePrivateJson(keyFile, generateSignetKey());
45
+ try {
46
+ return await awaitPairing(kingdom, keyFile, input);
47
+ } catch (error) {
48
+ await rm(keyFile, { force: true });
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ async function awaitPairing(
54
+ kingdom: string,
55
+ keyFile: string,
56
+ input: Parameters<typeof pairWithKingdom>[0],
57
+ ) {
58
+ const pending = await requestPairing(kingdom, keyFile, {
59
+ provider: 'archive',
60
+ deviceId: input.sourceId,
61
+ name: input.name,
62
+ resources: [],
63
+ expiresAt: null,
64
+ maxRequests: null,
65
+ maxConcurrent: 4,
66
+ });
67
+ input.onReview({
68
+ reviewCode: pending.reviewCode,
69
+ review: `${kingdom}/dashboard?reviewSignet=${pending.reviewCode}`,
70
+ expiresAt: pending.expiresAt,
71
+ });
72
+ const sleep = input.sleep ?? Bun.sleep;
73
+ while (Date.parse(pending.expiresAt) > Date.now()) {
74
+ const collected = await collectPairing(kingdom, keyFile, pending.deviceCode);
75
+ if (collected) {
76
+ const credentialFile = join(input.directory, `signet-${collected.signetId}.json`);
77
+ await saveCollectedSignet(credentialFile, kingdom, keyFile, collected);
78
+ return {
79
+ paired: true,
80
+ integrationId: collected.integrationId,
81
+ credentialFile,
82
+ libraries: await writableLibraries(credentialFile),
83
+ next: 'archive connect --kind kingdom --url KINGDOM --credential-file FILE --integration-id ID --resource-id ID --project-id PROJECT',
84
+ };
85
+ }
86
+ await sleep(pollMs);
87
+ }
88
+ throw new Error('The review code expired before it was approved; run archive pair again');
89
+ }
90
+
91
+ export async function verifyKingdomDestination(
92
+ destination: Extract<ArchiveDestination, { kind: 'kingdom' }>,
93
+ ) {
94
+ const libraries = await writableLibraries(destination.credentialFile);
95
+ if (
96
+ !libraries.some(
97
+ (library) =>
98
+ library.integrationId === destination.integrationId &&
99
+ library.resourceId === destination.resourceId,
100
+ )
101
+ )
102
+ throw new Error("This Archive's Signet does not grant sessions.write on that library");
103
+ }
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>(action: string, body: unknown = {}): Promise<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(30_000),
45
+ signal: AbortSignal.timeout(timeoutMs),
42
46
  headers: {
43
47
  authorization: `Bearer ${this.options.token}`,
44
48
  'content-type': 'application/json',