@hanamesh/dsh-app-host 0.2.0-rc.2

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.
Files changed (82) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +104 -0
  3. package/consistency.json +101 -0
  4. package/dist/client-ui.d.ts +19 -0
  5. package/dist/client-ui.js +271 -0
  6. package/dist/client.d.ts +16 -0
  7. package/dist/client.js +42 -0
  8. package/dist/consistency.js +12 -0
  9. package/dist/descriptor.js +141 -0
  10. package/dist/dsh.d.ts +25 -0
  11. package/dist/dsh.js +194 -0
  12. package/dist/errors.js +25 -0
  13. package/dist/gateway.js +224 -0
  14. package/dist/guardian.js +113 -0
  15. package/dist/index.d.ts +130 -0
  16. package/dist/index.js +15 -0
  17. package/dist/launcher.js +17 -0
  18. package/dist/library/catalog.js +108 -0
  19. package/dist/library/domain.js +8 -0
  20. package/dist/library/install.js +87 -0
  21. package/dist/library/installed.js +67 -0
  22. package/dist/library/locate.js +79 -0
  23. package/dist/library/routes.js +17 -0
  24. package/dist/library/service.js +76 -0
  25. package/dist/manager.js +605 -0
  26. package/dist/provision/LICENSE +6 -0
  27. package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
  28. package/dist/provision/archive.d.ts +28 -0
  29. package/dist/provision/archive.js +66 -0
  30. package/dist/provision/check.d.ts +10 -0
  31. package/dist/provision/check.js +51 -0
  32. package/dist/provision/checkpoints.d.ts +12 -0
  33. package/dist/provision/checkpoints.js +23 -0
  34. package/dist/provision/cli.d.ts +1 -0
  35. package/dist/provision/cli.js +169 -0
  36. package/dist/provision/download.d.ts +21 -0
  37. package/dist/provision/download.js +242 -0
  38. package/dist/provision/errors.d.ts +11 -0
  39. package/dist/provision/errors.js +13 -0
  40. package/dist/provision/extract.d.ts +17 -0
  41. package/dist/provision/extract.js +124 -0
  42. package/dist/provision/fsutil.d.ts +8 -0
  43. package/dist/provision/fsutil.js +71 -0
  44. package/dist/provision/index.d.ts +7 -0
  45. package/dist/provision/index.js +6 -0
  46. package/dist/provision/ledger.d.ts +14 -0
  47. package/dist/provision/ledger.js +50 -0
  48. package/dist/provision/manifest.d.ts +11 -0
  49. package/dist/provision/manifest.js +171 -0
  50. package/dist/provision/paths.d.ts +27 -0
  51. package/dist/provision/paths.js +95 -0
  52. package/dist/provision/platform.d.ts +6 -0
  53. package/dist/provision/platform.js +7 -0
  54. package/dist/provision/provision.d.ts +17 -0
  55. package/dist/provision/provision.js +454 -0
  56. package/dist/provision/tar.d.ts +7 -0
  57. package/dist/provision/tar.js +169 -0
  58. package/dist/provision/types.d.ts +174 -0
  59. package/dist/provision/types.js +7 -0
  60. package/dist/provision/zip.d.ts +4 -0
  61. package/dist/provision/zip.js +194 -0
  62. package/dist/router/broker.js +161 -0
  63. package/dist/router/domain.js +18 -0
  64. package/dist/router/receipts-domain.js +8 -0
  65. package/dist/router/receipts.js +156 -0
  66. package/dist/router/routes.js +48 -0
  67. package/dist/router/sources.js +87 -0
  68. package/dist/routes.js +74 -0
  69. package/dist/runtime.js +222 -0
  70. package/dist/store.js +250 -0
  71. package/dist/usage-evidence.js +117 -0
  72. package/docs/APP_PACKAGE.md +38 -0
  73. package/docs/CONTRACT.md +190 -0
  74. package/docs/DSH_INTEGRATION.md +43 -0
  75. package/docs/LIBRARY.md +45 -0
  76. package/docs/LICENSES.json +185 -0
  77. package/docs/PROVENANCE.json +63 -0
  78. package/docs/SECURITY.md +52 -0
  79. package/package.json +112 -0
  80. package/profile/cordis.patch.yml +4 -0
  81. package/schemas/catalog-provider-page.schema.json +376 -0
  82. package/schemas/catalog-source.schema.json +151 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Public types of @hanamesh/lib-provision.
3
+ *
4
+ * The manifest is authored by the caller; this library only executes it.
5
+ * Nothing here is ever guessed from the host machine except the platform key.
6
+ */
7
+ /** Normalised `${process.platform}-${process.arch}` (e.g. `darwin-arm64`). */
8
+ export type PlatformKey = string;
9
+ export type SourceKind = 'https' | 'file';
10
+ export interface ManifestSource {
11
+ /** Stable id, referenced by `sourceOrder` / `--source`. */
12
+ id: string;
13
+ kind: SourceKind;
14
+ /** Base URL. `https:` or `file:` only. Item assets are resolved relative to it. */
15
+ base: string;
16
+ }
17
+ export type ArchiveKind = 'tar.gz' | 'zip';
18
+ export interface PlatformAsset {
19
+ /** File name (or relative path) appended to each source base, unless `url` is given. */
20
+ asset: string;
21
+ /** Lower-case hex sha256 of the archive. Mandatory: an item without it is refused. */
22
+ sha256: string;
23
+ /** Expected byte size (informational; used for progress totals and sanity checks). */
24
+ size?: number;
25
+ /** Leading path components to strip on extraction (like `tar --strip-components`). */
26
+ strip?: number;
27
+ /** Absolute `https:`/`file:` URL overriding `sources` resolution for this asset. */
28
+ url?: string;
29
+ }
30
+ export interface VerifySpec {
31
+ /** Path inside the extracted tree (after strip) of the executable to run. */
32
+ exec: string;
33
+ args?: string[];
34
+ /** Exact expected stdout (trimmed). Omit to only require exit code 0. */
35
+ expect?: string;
36
+ /** Timeout in ms (default 60000). */
37
+ timeoutMs?: number;
38
+ }
39
+ export interface ManifestItem {
40
+ id: string;
41
+ version: string;
42
+ kind: ArchiveKind;
43
+ /** Target directory relative to root (POSIX separators). */
44
+ installTo: string;
45
+ platforms: Record<PlatformKey, PlatformAsset>;
46
+ verify?: VerifySpec;
47
+ }
48
+ export interface Manifest {
49
+ schema: 1;
50
+ sources: ManifestSource[];
51
+ items: ManifestItem[];
52
+ }
53
+ export type PlanState = 'absent' | 'installed' | 'stale' | 'unsupported';
54
+ export interface PlanEntry {
55
+ item: string;
56
+ version: string;
57
+ platform: PlatformKey;
58
+ /** Undefined when `state === 'unsupported'`. */
59
+ asset?: string;
60
+ sha256?: string;
61
+ /** Absolute target path (external form, never `\\?\`-prefixed). */
62
+ target: string;
63
+ state: PlanState;
64
+ /** Ledger entry currently recorded for this item, if any. */
65
+ current?: LedgerEntry;
66
+ }
67
+ export interface LedgerEntry {
68
+ version: string;
69
+ sha256: string;
70
+ platform: PlatformKey;
71
+ /** ISO-8601 timestamp. */
72
+ installedAt: string;
73
+ /** Absolute target path at install time (external form). */
74
+ target: string;
75
+ /** `installTo` relative to root; this is what `verify`/`remove` resolve against. */
76
+ installTo: string;
77
+ /** Source id (or `url` when the asset carried its own URL). */
78
+ source: string;
79
+ }
80
+ export interface Ledger {
81
+ schema: 1;
82
+ items: Record<string, LedgerEntry>;
83
+ }
84
+ export interface OwnedFile {
85
+ /** Relative POSIX path inside the target. */
86
+ path: string;
87
+ size: number;
88
+ sha256: string;
89
+ /** POSIX mode bits (0o777 mask). */
90
+ mode: number;
91
+ }
92
+ export interface OwnedSymlink {
93
+ path: string;
94
+ target: string;
95
+ }
96
+ /** `<target>.manifest.json` — the deletion authority for `remove`. */
97
+ export interface OwnershipManifest {
98
+ schema: 1;
99
+ item: string;
100
+ version: string;
101
+ platform: PlatformKey;
102
+ /** sha256 of the archive this tree came from. */
103
+ sha256: string;
104
+ source: string;
105
+ installTo: string;
106
+ files: OwnedFile[];
107
+ dirs: string[];
108
+ symlinks: OwnedSymlink[];
109
+ }
110
+ export type ProgressPhase = 'download' | 'verify' | 'extract' | 'check' | 'promote';
111
+ export interface ProgressEvent {
112
+ item: string;
113
+ phase: ProgressPhase;
114
+ bytes?: number | undefined;
115
+ total?: number | undefined;
116
+ /** Source id in use (download phase). */
117
+ source?: string | undefined;
118
+ /** Free-form note (e.g. `resume`, `source-failed`), never containing URL queries. */
119
+ note?: string | undefined;
120
+ }
121
+ export interface ProvisionOptions {
122
+ root: string;
123
+ platform?: PlatformKey;
124
+ onProgress?: (event: ProgressEvent) => void;
125
+ signal?: AbortSignal;
126
+ /** Source ids to try, in order. Unlisted sources are not used. Default: manifest order. */
127
+ sourceOrder?: string[];
128
+ /** Restrict to these item ids. */
129
+ only?: string[];
130
+ /** Re-install even when the ledger says the same sha256 is installed. */
131
+ force?: boolean;
132
+ }
133
+ export interface PlanOptions {
134
+ root: string;
135
+ platform?: PlatformKey;
136
+ only?: string[];
137
+ }
138
+ export type ProvisionAction = 'installed' | 'skipped' | 'unsupported';
139
+ export interface ProvisionResult {
140
+ item: string;
141
+ version: string;
142
+ platform: PlatformKey;
143
+ target: string;
144
+ action: ProvisionAction;
145
+ sha256?: string;
146
+ source?: string;
147
+ }
148
+ export interface RemoveResult {
149
+ item: string;
150
+ target: string;
151
+ removedFiles: number;
152
+ removedSymlinks: number;
153
+ removedDirs: number;
154
+ /** Paths that were present in the target but not in the ownership manifest; left untouched. */
155
+ kept: string[];
156
+ }
157
+ export type VerifyState = 'ok' | 'repaired' | 'missing' | 'corrupt' | 'unmanaged';
158
+ export interface VerifyItem {
159
+ item: string;
160
+ target: string;
161
+ state: VerifyState;
162
+ /** Human-readable detail (what was repaired / which file mismatched). */
163
+ detail?: string;
164
+ version?: string;
165
+ sha256?: string;
166
+ }
167
+ export interface VerifyResult {
168
+ root: string;
169
+ items: VerifyItem[];
170
+ /** True when every item is `ok` or `repaired`. */
171
+ ok: boolean;
172
+ /** Leftovers cleaned under `.provision/` (staging, trash, temp files). */
173
+ cleaned: string[];
174
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Public types of @hanamesh/lib-provision.
3
+ *
4
+ * The manifest is authored by the caller; this library only executes it.
5
+ * Nothing here is ever guessed from the host machine except the platform key.
6
+ */
7
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { ArchiveEntry } from './archive.js';
2
+ export declare function crc32Update(crc: number, buf: Uint8Array): number;
3
+ /** Read a zip via its central directory (the only trustworthy index). Store and deflate only. */
4
+ export declare function readZip(file: string, item: string): AsyncGenerator<ArchiveEntry>;
@@ -0,0 +1,194 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import { createInflateRaw } from 'node:zlib';
4
+ import { ProvisionError } from './errors.js';
5
+ const CRC_TABLE = (() => {
6
+ const t = new Uint32Array(256);
7
+ for (let n = 0; n < 256; n++) {
8
+ let c = n;
9
+ for (let k = 0; k < 8; k++)
10
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
11
+ t[n] = c >>> 0;
12
+ }
13
+ return t;
14
+ })();
15
+ export function crc32Update(crc, buf) {
16
+ let c = ~crc >>> 0;
17
+ for (let i = 0; i < buf.length; i++)
18
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
19
+ return ~c >>> 0;
20
+ }
21
+ const EOCD_SIG = 0x06054b50;
22
+ const CEN_SIG = 0x02014b50;
23
+ const LOC_SIG = 0x04034b50;
24
+ const Z64_EOCD_SIG = 0x06064b50;
25
+ const Z64_LOC_SIG = 0x07064b50;
26
+ async function readRange(handle, offset, length) {
27
+ const buf = Buffer.alloc(length);
28
+ let done = 0;
29
+ while (done < length) {
30
+ const { bytesRead } = await handle.read(buf, done, length - done, offset + done);
31
+ if (bytesRead === 0)
32
+ break;
33
+ done += bytesRead;
34
+ }
35
+ return done === length ? buf : buf.subarray(0, done);
36
+ }
37
+ function parseZip64Extra(extra, need) {
38
+ let pos = 0;
39
+ while (pos + 4 <= extra.length) {
40
+ const id = extra.readUInt16LE(pos);
41
+ const len = extra.readUInt16LE(pos + 2);
42
+ if (id === 0x0001) {
43
+ const out = {};
44
+ let p = pos + 4;
45
+ if (need.size) {
46
+ out.size = Number(extra.readBigUInt64LE(p));
47
+ p += 8;
48
+ }
49
+ if (need.csize) {
50
+ out.compressedSize = Number(extra.readBigUInt64LE(p));
51
+ p += 8;
52
+ }
53
+ if (need.offset) {
54
+ out.localHeaderOffset = Number(extra.readBigUInt64LE(p));
55
+ p += 8;
56
+ }
57
+ return out;
58
+ }
59
+ pos += 4 + len;
60
+ }
61
+ return {};
62
+ }
63
+ async function readCentralDirectory(handle, fileSize, item) {
64
+ const tailLen = Math.min(fileSize, 65557);
65
+ const tail = await readRange(handle, fileSize - tailLen, tailLen);
66
+ let eocd = -1;
67
+ for (let i = tail.length - 22; i >= 0; i--) {
68
+ if (tail.readUInt32LE(i) === EOCD_SIG) {
69
+ eocd = i;
70
+ break;
71
+ }
72
+ }
73
+ if (eocd === -1)
74
+ throw new ProvisionError('E_ARCHIVE', 'zip: end of central directory not found', { item });
75
+ let count = tail.readUInt16LE(eocd + 10);
76
+ let cdSize = tail.readUInt32LE(eocd + 12);
77
+ let cdOffset = tail.readUInt32LE(eocd + 16);
78
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) {
79
+ // ZIP64: locator sits right before the EOCD.
80
+ const locPos = eocd - 20;
81
+ if (locPos < 0 || tail.readUInt32LE(locPos) !== Z64_LOC_SIG)
82
+ throw new ProvisionError('E_ARCHIVE', 'zip64: locator missing', { item });
83
+ const z64Offset = Number(tail.readBigUInt64LE(locPos + 8));
84
+ const z64 = await readRange(handle, z64Offset, 56);
85
+ if (z64.readUInt32LE(0) !== Z64_EOCD_SIG)
86
+ throw new ProvisionError('E_ARCHIVE', 'zip64: EOCD record missing', { item });
87
+ count = Number(z64.readBigUInt64LE(32));
88
+ cdSize = Number(z64.readBigUInt64LE(40));
89
+ cdOffset = Number(z64.readBigUInt64LE(48));
90
+ }
91
+ const cd = await readRange(handle, cdOffset, cdSize);
92
+ const entries = [];
93
+ let pos = 0;
94
+ for (let i = 0; i < count; i++) {
95
+ if (pos + 46 > cd.length || cd.readUInt32LE(pos) !== CEN_SIG)
96
+ throw new ProvisionError('E_ARCHIVE', 'zip: central directory corrupt', { item });
97
+ const madeByHost = cd.readUInt16LE(pos + 4) >> 8;
98
+ const flags = cd.readUInt16LE(pos + 8);
99
+ const method = cd.readUInt16LE(pos + 10);
100
+ const crc32 = cd.readUInt32LE(pos + 16);
101
+ let compressedSize = cd.readUInt32LE(pos + 20);
102
+ let size = cd.readUInt32LE(pos + 24);
103
+ const nameLen = cd.readUInt16LE(pos + 28);
104
+ const extraLen = cd.readUInt16LE(pos + 30);
105
+ const commentLen = cd.readUInt16LE(pos + 32);
106
+ const externalAttrs = cd.readUInt32LE(pos + 38);
107
+ let localHeaderOffset = cd.readUInt32LE(pos + 42);
108
+ const nameBytes = cd.subarray(pos + 46, pos + 46 + nameLen);
109
+ const name = (flags & 0x800) !== 0 ? nameBytes.toString('utf8') : nameBytes.toString('latin1');
110
+ const extra = cd.subarray(pos + 46 + nameLen, pos + 46 + nameLen + extraLen);
111
+ const need = { size: size === 0xffffffff, csize: compressedSize === 0xffffffff, offset: localHeaderOffset === 0xffffffff };
112
+ if (need.size || need.csize || need.offset) {
113
+ const z = parseZip64Extra(extra, need);
114
+ if (z.size !== undefined)
115
+ size = z.size;
116
+ if (z.compressedSize !== undefined)
117
+ compressedSize = z.compressedSize;
118
+ if (z.localHeaderOffset !== undefined)
119
+ localHeaderOffset = z.localHeaderOffset;
120
+ }
121
+ entries.push({ name, method, crc32, compressedSize, size, localHeaderOffset, externalAttrs, madeByHost, flags });
122
+ pos += 46 + nameLen + extraLen + commentLen;
123
+ }
124
+ return entries;
125
+ }
126
+ const S_IFMT = 0o170000;
127
+ const S_IFLNK = 0o120000;
128
+ const S_IFDIR = 0o040000;
129
+ /** Read a zip via its central directory (the only trustworthy index). Store and deflate only. */
130
+ export async function* readZip(file, item) {
131
+ const handle = await fs.open(file, 'r');
132
+ try {
133
+ const { size: fileSize } = await handle.stat();
134
+ const entries = await readCentralDirectory(handle, fileSize, item);
135
+ for (const e of entries) {
136
+ if ((e.flags & 0x1) !== 0)
137
+ throw new ProvisionError('E_ARCHIVE', `zip: encrypted entry ${e.name}`, { item });
138
+ if (e.method !== 0 && e.method !== 8)
139
+ throw new ProvisionError('E_ARCHIVE', `zip: unsupported compression method ${e.method} for ${e.name}`, { item });
140
+ const local = await readRange(handle, e.localHeaderOffset, 30);
141
+ if (local.length < 30 || local.readUInt32LE(0) !== LOC_SIG)
142
+ throw new ProvisionError('E_ARCHIVE', `zip: local header corrupt for ${e.name}`, { item });
143
+ const dataOffset = e.localHeaderOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
144
+ if (dataOffset + e.compressedSize > fileSize)
145
+ throw new ProvisionError('E_ARCHIVE', `zip: entry ${e.name} extends past end of file`, { item });
146
+ const unixMode = e.madeByHost === 3 ? (e.externalAttrs >>> 16) & 0o177777 : 0;
147
+ let kind;
148
+ if (e.name.endsWith('/') || (unixMode & S_IFMT) === S_IFDIR || (e.externalAttrs & 0x10) !== 0)
149
+ kind = 'dir';
150
+ else if ((unixMode & S_IFMT) === S_IFLNK)
151
+ kind = 'symlink';
152
+ else
153
+ kind = 'file';
154
+ const expectedCrc = e.crc32;
155
+ const expectedSize = e.size;
156
+ const makeBody = () => (async function* () {
157
+ if (kind === 'dir' || e.compressedSize === 0 && expectedSize === 0)
158
+ return;
159
+ const raw = createReadStream(file, { start: dataOffset, end: dataOffset + e.compressedSize - 1, highWaterMark: 1 << 20 });
160
+ const stream = e.method === 8 ? raw.pipe(createInflateRaw()) : raw;
161
+ let crc = 0;
162
+ let n = 0;
163
+ for await (const chunk of stream) {
164
+ const c = chunk;
165
+ crc = crc32Update(crc, c);
166
+ n += c.length;
167
+ yield c;
168
+ }
169
+ if (n !== expectedSize)
170
+ throw new ProvisionError('E_ARCHIVE', `zip: size mismatch for ${e.name}`, { item });
171
+ if (crc !== expectedCrc)
172
+ throw new ProvisionError('E_ARCHIVE', `zip: crc32 mismatch for ${e.name}`, { item });
173
+ })();
174
+ const entry = {
175
+ kind,
176
+ name: kind === 'dir' && e.name.endsWith('/') ? e.name.slice(0, -1) : e.name,
177
+ mode: unixMode & 0o7777,
178
+ size: kind === 'file' ? expectedSize : 0,
179
+ body: makeBody(),
180
+ };
181
+ if (kind === 'symlink') {
182
+ const parts = [];
183
+ for await (const c of entry.body)
184
+ parts.push(c);
185
+ entry.linkTarget = Buffer.concat(parts).toString('utf8');
186
+ entry.body = (async function* () { })();
187
+ }
188
+ yield entry;
189
+ }
190
+ }
191
+ finally {
192
+ await handle.close();
193
+ }
194
+ }
@@ -0,0 +1,161 @@
1
+ // Migrated from hanamesh-plugin-auth-apikey 8bb7ba7d2ac18a4dc3004674b5fc2b9aa5e3b0f5
2
+ // source sha256 e903802b3d1473b643693634a0e3ffcf506b6006d2522c44553a6dc8b4a9e00e.
3
+ // Detection, adoption and key mutation were intentionally not migrated: Router only grants existing providers.
4
+ import { providerOf } from './sources.js';
5
+
6
+ const REF=/^[A-Za-z_][A-Za-z_0-9]*$/, APP=/^[a-z0-9][a-z0-9-]*$/;
7
+ const KEY=/^hanamesh-auth-oauth\/([a-z0-9-]+)--app-([a-z0-9-]+)$/;
8
+ const error=code=>Object.assign(new Error(code),{code});
9
+ export const entryId=entry=>entry.projection==='file'?`file:${entry.base??'home'}:${entry.path}`:`env:${entry.env}`;
10
+ const emptyApp=()=>({mode:'managed',grants:{},ledger:{},revoked:{}});
11
+
12
+ /** `onInject` (T6): called once per credentialResolver run that resolved at least one env route, with the routes in
13
+ * declaration order `{providerId, model|null}` — the receipt ledger binds the instance to the first one. Never sees values. */
14
+ export function createRouter({credentials,domain,apps,sources,oauth=()=>undefined,onInject=()=>{},receiptLedger}) {
15
+ let writing=Promise.resolve(); const instancePending=new Map();
16
+ const snapshot=()=>domain.global.get();
17
+ const appState=appId=>snapshot().apps[appId]??emptyApp();
18
+ function updateApp(appId,change){
19
+ const next=writing.then(async()=>{const old=snapshot(),app=structuredClone(old.apps[appId]??emptyApp());change(app);
20
+ await domain.global.set({...old,apps:{...old.apps,[appId]:app}});});writing=next.catch(()=>{});return next;
21
+ }
22
+ function declaration(appId){
23
+ if(!APP.test(appId))throw error('INVALID_APP');
24
+ const app=apps.list().apps.find(item=>item.id===appId);if(!app)throw error('APP_NOT_FOUND');
25
+ return app.deployments.filter(d=>d.mode==='owned').flatMap(d=>d.credentialEnv??[]);
26
+ }
27
+ const declared=(appId,id)=>declaration(appId).find(entry=>entryId(entry)===id);
28
+ function validSubject(entry,subject,appId){
29
+ if(!subject||!['api-key','provider','grant'].includes(subject.kind))throw error('INVALID_SUBJECT');
30
+ if(entry.kind&&entry.kind!==(subject.kind==='provider'?'api-key':subject.kind))throw error('SUBJECT_KIND_MISMATCH');
31
+ let provider;
32
+ if(subject.kind==='api-key'){if(!REF.test(subject.ref))throw error('INVALID_REF');provider=providerOf(subject.ref);}
33
+ if(subject.kind==='provider'){if(!/^[a-z0-9][a-z0-9-]*$/.test(subject.providerId))throw error('INVALID_PROVIDER');provider=subject.providerId;}
34
+ if(subject.kind==='grant'){
35
+ const match=KEY.exec(subject.key);if(!match||match[2]!==appId)throw error('CONSUMER_MISMATCH');provider=match[1];
36
+ }
37
+ if(!accepts(entry,provider))throw error('PROVIDER_MISMATCH');
38
+ }
39
+ async function providerDirectory(){return await sources.list();}
40
+ /** Providers an api-key entry accepts: its declared ids, plus the OpenAI-compatible Coding OAuth
41
+ * gateway wherever `openai` is accepted (same wire protocol, `{{baseUrl}}` carries the difference). */
42
+ const accepts=(entry,providerId)=>!entry.providers?.length||entry.providers.includes(providerId)
43
+ ||(providerId==='coding-oauth-gateway'&&entry.providers.includes('openai'));
44
+ /** Auto-route (2026-09-21 contract): when the profile already holds a configured provider the entry accepts
45
+ * and the user has not revoked it, the Router grants it without a click. Declared order wins; the gateway
46
+ * is the last resort. Explicit grants and `app-owned` mode always take precedence. */
47
+ function autoSubject(entry,app,providers){
48
+ const id=entryId(entry);if(entry.kind!=='api-key'||entry.projection==='file'||app.revoked?.[id])return undefined;
49
+ // A slot that names no provider is filled by a sibling's `sets` (or by the app itself); the Router never guesses for it.
50
+ if(!entry.providers?.length)return undefined;
51
+ const configured=providers.filter(p=>p.state==='configured'&&accepts(entry,p.id));
52
+ const ordered=[...(entry.providers??[]).map(pid=>configured.find(p=>p.id===pid)).filter(Boolean),...configured.filter(p=>p.id==='coding-oauth-gateway')];
53
+ const found=ordered[0]??configured[0];if(!found)return undefined;
54
+ return found.ref?{kind:'api-key',ref:found.ref}:{kind:'provider',providerId:found.id};
55
+ }
56
+ /** The app's own default from `sets: {X:'{{model|<default>}}'}` — shown as「应用默认」in the routing table. */
57
+ function declaredDefaultModel(entry){
58
+ for(const value of Object.values(entry.sets??{})){const match=/\{\{model\|([^}]*)\}\}/.exec(value);if(match)return match[1];}
59
+ return undefined;
60
+ }
61
+ function template(value,vars){
62
+ return value.replace(/\{\{(provider|baseUrl|model)(?:\|([^}]*))?\}\}/g,(_,name,fallback)=>vars[name]||fallback||'');
63
+ }
64
+ const api={
65
+ providers:providerDirectory,
66
+ async enableGateway(enabled){return await sources.enableGateway(enabled);},
67
+ async mode(appId){declaration(appId);return appState(appId).mode;},
68
+ async setMode(appId,mode){declaration(appId);if(!['managed','app-owned'].includes(mode))throw error('INVALID_MODE');
69
+ await updateApp(appId,app=>{app.mode=mode;});},
70
+ async plan(appId){
71
+ const entries=declaration(appId),app=appState(appId),oauthProvider=oauth(),providers=await providerDirectory();
72
+ const oauthInfos=oauthProvider?await oauthProvider.providers():[];
73
+ const derived=new Set(entries.flatMap(entry=>Object.keys(entry.sets??{})));
74
+ const items=await Promise.all(entries.map(async entry=>{
75
+ const id=entryId(entry),grant=app.grants[id],item={entry,state:'missing'};
76
+ if(entry.env&&derived.has(entry.env))item.derived=true;
77
+ const defaultModel=declaredDefaultModel(entry);if(defaultModel)item.defaultModel=defaultModel;
78
+ if(app.mode==='app-owned'){item.state='app-owned';return item;}
79
+ if(grant){item.granted=grant.subject;if(grant.model)item.model=grant.model;
80
+ let present=false;
81
+ if(grant.subject.kind==='api-key')present=(await credentials.describe(grant.subject.ref)).configured;
82
+ if(grant.subject.kind==='provider')present=providers.some(p=>p.id===grant.subject.providerId&&p.state==='configured');
83
+ if(grant.subject.kind==='grant')present=!!oauthProvider&&(await credentials.describeRecord(grant.subject.key)).configured;
84
+ item.state=present?'granted':'missing';
85
+ if(grant.subject.kind==='grant'){item.riskNotice=oauthInfos.find(x=>x.id===KEY.exec(grant.subject.key)?.[1])?.riskNotice;item.projectedVersion=app.ledger[id]?.projectedVersion;}
86
+ }else{
87
+ const auto=autoSubject(entry,app,providers);
88
+ if(auto){item.granted=auto;item.auto=true;item.state='auto';}
89
+ else if(app.revoked?.[id])item.state='revoked';
90
+ }
91
+ const routed=item.granted&&(item.granted.kind==='provider'?providers.find(p=>p.id===item.granted.providerId):item.granted.kind==='api-key'?providers.find(p=>p.ref===item.granted.ref):undefined);
92
+ if(routed?.models?.length)item.models=routed.models;
93
+ return item;
94
+ }));return{appId,mode:app.mode,items};
95
+ },
96
+ async grant(appId,id,subject,opts={}){
97
+ const entry=declared(appId,id);if(!entry)throw error('ENTRY_UNKNOWN');validSubject(entry,subject,appId);
98
+ if(subject.kind==='grant'&&opts.riskAcknowledged!==true)throw error('RISK_NOT_ACKNOWLEDGED');
99
+ if(subject.kind==='provider'&&!((await providerDirectory()).some(p=>p.id===subject.providerId)))throw error('ROUTER_PROVIDER_ABSENT');
100
+ await updateApp(appId,app=>{app.grants[id]={subject,...(opts.model?{model:opts.model}:{}),grantedAt:new Date().toISOString(),riskAcknowledged:opts.riskAcknowledged===true};delete app.revoked?.[id];});
101
+ return await api.plan(appId);
102
+ },
103
+ /** Model rides with the route: set it on the routed slot (an auto-routed slot becomes an explicit grant of the same provider). '' = app default. */
104
+ async setModel(appId,id,model){
105
+ const entry=declared(appId,id);if(!entry)throw error('ENTRY_UNKNOWN');
106
+ if(typeof model!=='string'||model.length>200||/[\s]/.test(model))throw error('INVALID_MODEL');
107
+ const app=appState(appId),subject=app.grants[id]?.subject??autoSubject(entry,app,await providerDirectory());
108
+ if(!subject)throw error('ENTRY_NOT_ROUTED');
109
+ await updateApp(appId,app=>{const grant=app.grants[id]??{subject,grantedAt:new Date().toISOString(),riskAcknowledged:false};if(model)grant.model=model;else delete grant.model;app.grants[id]=grant;delete app.revoked?.[id];});
110
+ return await api.plan(appId);
111
+ },
112
+ /** T6: local receipt rows (hourly, content-free) for one app or all apps. */
113
+ receipts(appId){if(appId!==undefined)declaration(appId);return {items:receiptLedger?receiptLedger.list(appId===undefined?{}:{appId}):[]};},
114
+ async revoke(appId,id){if(!declared(appId,id))throw error('ENTRY_UNKNOWN');await updateApp(appId,app=>{delete app.grants[id];app.revoked??={};app.revoked[id]=true;});return await api.plan(appId);},
115
+ async credentialResolver(input){
116
+ await writing;const{appId,instanceId}=input,current=declaration(appId),byId=new Map(current.map(e=>[entryId(e),e]));
117
+ const app=appState(appId),env={},files=[],secrets=[],pending=new Map(),routes=[];
118
+ if(app.mode==='app-owned'){
119
+ for(const[id,ledger]of Object.entries(app.ledger))if(byId.has(id)&&id.startsWith('file:')&&(ledger.pendingVersion??ledger.projectedVersion)!==undefined&&ledger.removedForVersion!==(ledger.pendingVersion??ledger.projectedVersion))files.push({path:byId.get(id).path,policy:'remove'});
120
+ return files.length?{files}:undefined;
121
+ }
122
+ const applySets=(entry,vars)=>{for(const[name,value]of Object.entries(entry.sets??{}))if(byId.has('env:'+name)){const expanded=template(value,vars);if(expanded)env[name]=expanded;}};
123
+ const directory=await providerDirectory();
124
+ for(const entry of input.credentialEnv){
125
+ const id=entryId(entry),matched=byId.get(id);if(!matched||JSON.stringify(matched)!==JSON.stringify(entry))continue;
126
+ const auto=app.grants[id]?undefined:autoSubject(matched,app,directory),grant=app.grants[id]??(auto?{subject:auto}:undefined);if(!grant)continue;validSubject(entry,grant.subject,appId);
127
+ if(entry.projection==='file'){
128
+ if(grant.subject.kind!=='grant'||!oauth())continue;if(!(await credentials.describeRecord(grant.subject.key)).configured)continue;
129
+ const projection=await oauth().project(grant.subject.key,entry.format);if(typeof projection.content!=='string')continue;
130
+ const version=projection.version,old=app.ledger[id];files.push({path:entry.path,content:projection.content,policy:(old?.pendingVersion??old?.projectedVersion)===version?'if-absent':'overwrite'});
131
+ pending.set(id,version);secrets.push(projection.content);applySets(entry,{provider:KEY.exec(grant.subject.key)?.[1]??'',baseUrl:'',model:grant.model??''});
132
+ }else if(entry.env&&['api-key','provider'].includes(grant.subject.kind)){
133
+ const resolved=await sources.resolve(grant.subject);if(!resolved?.value)continue;
134
+ env[entry.env]=resolved.value;secrets.push(resolved.value);
135
+ // Model choice belongs to the app (`{{model|<app default>}}`); the Router only carries an explicitly granted model.
136
+ const provider=resolved.provider??{};
137
+ applySets(entry,{provider:provider.id??'',baseUrl:provider.baseUrl??'',model:grant.model??''});
138
+ if(provider.id)routes.push({providerId:provider.id,model:grant.model??declaredDefaultModel(entry)??null});
139
+ }else if(entry.env&&grant.subject.kind==='grant'&&oauth()){
140
+ const projection=await oauth().project(grant.subject.key,'env');if(projection.format==='env'&&typeof projection.env[entry.env]==='string'){
141
+ env[entry.env]=projection.env[entry.env];secrets.push(projection.env[entry.env]);applySets(entry,{provider:KEY.exec(grant.subject.key)?.[1]??'',baseUrl:'',model:grant.model??''});
142
+ const oauthProvider=KEY.exec(grant.subject.key)?.[1];if(oauthProvider)routes.push({providerId:oauthProvider,model:grant.model??declaredDefaultModel(entry)??null});
143
+ }
144
+ }
145
+ }
146
+ for(const[id,ledger]of Object.entries(app.ledger))if(id.startsWith('file:')&&byId.has(id)&&!app.grants[id]&&(ledger.pendingVersion??ledger.projectedVersion)!==undefined&&ledger.removedForVersion!==(ledger.pendingVersion??ledger.projectedVersion))files.push({path:byId.get(id).path,policy:'remove'});
147
+ if(pending.size){await updateApp(appId,app=>{for(const[id,version]of pending)app.ledger[id]={...app.ledger[id],pendingVersion:version,pendingInstanceId:instanceId};});instancePending.set(instanceId,{appId,pending});}
148
+ // T6: receipts learn the route here, never the value. A failing observer must not block the launch.
149
+ if(routes.length){try{onInject({appId,instanceId,routes});}catch{}}
150
+ return Object.keys(env).length||files.length?{env,files,secrets}:undefined;
151
+ },
152
+ async observe(event){
153
+ if(!['credential.injected','credential.file-removed'].includes(event.type))return;
154
+ const pending=instancePending.get(event.instanceId),appId=pending?.appId??Object.keys(snapshot().apps).find(id=>Object.entries(appState(id).ledger).some(([key,ledger])=>key.startsWith('file:')&&(ledger.pendingInstanceId===event.instanceId||event.names?.includes(`file:${key.split(':').slice(2).join(':')}`))));
155
+ if(!appId)return;
156
+ for(const name of event.names??[]){if(!name.startsWith('file:'))continue;const id=Object.keys(appState(appId).ledger).concat([...pending?.pending.keys()??[]]).find(key=>key.startsWith('file:')&&`file:${key.split(':').slice(2).join(':')}`===name);if(!id)continue;
157
+ await updateApp(appId,app=>{const ledger=app.ledger[id]??{};if(event.type==='credential.injected'&&ledger.pendingInstanceId===event.instanceId){ledger.projectedVersion=ledger.pendingVersion;delete ledger.removedForVersion;}if(event.type==='credential.file-removed')ledger.removedForVersion=ledger.pendingVersion??ledger.projectedVersion;app.ledger[id]=ledger;});}
158
+ },
159
+ };
160
+ return api;
161
+ }
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import { defineDomain } from '@deepseek-ai/dsh-storage-domain';
3
+
4
+ const subject = z.union([
5
+ z.object({ kind:z.literal('api-key'), ref:z.string() }),
6
+ z.object({ kind:z.literal('provider'), providerId:z.string() }),
7
+ z.object({ kind:z.literal('grant'), key:z.string() }),
8
+ ]);
9
+ const ledger = z.object({ projectedVersion:z.number().optional(), pendingVersion:z.number().optional(),
10
+ pendingInstanceId:z.string().optional(), removedForVersion:z.number().optional() });
11
+ export const routerDomainSpec = defineDomain({
12
+ name:'hanamesh_router', version:1, layout:'single', tables:{},
13
+ global:{ schema:z.object({ schema:z.literal(1), apps:z.record(z.string(),z.object({
14
+ mode:z.enum(['managed','app-owned']), grants:z.record(z.string(),z.object({
15
+ subject, model:z.string().optional(), grantedAt:z.string(), riskAcknowledged:z.boolean(),
16
+ })), ledger:z.record(z.string(),ledger), revoked:z.record(z.string(),z.boolean()).optional(),
17
+ })) }), initial:{ schema:1, apps:{} } },
18
+ });
@@ -0,0 +1,8 @@
1
+ import { defineDomain } from '@deepseek-ai/dsh-storage-domain';
2
+ import { receiptsGlobalSchema, receiptsGlobalInitial } from './receipts.js';
3
+
4
+ /** DSH-only: opened by the plugin entry (`src/dsh.js`), never by the library entry. */
5
+ export const receiptsDomainSpec = defineDomain({
6
+ name:'hanamesh_router_receipts', version:1, layout:'single', tables:{},
7
+ global:{ schema:receiptsGlobalSchema, initial:receiptsGlobalInitial },
8
+ });