@learncard/cli 3.5.1 → 3.6.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/CHANGELOG.md +44 -0
- package/README.md +230 -2
- package/dist/index.js +4337 -986
- package/examples/branded.network.yaml +20 -0
- package/examples/delegated-service-account.network.yaml +23 -0
- package/examples/minimal.network.yaml +7 -0
- package/examples/self-hosted-signing.network.yaml +10 -0
- package/examples/service-account.network.yaml +13 -0
- package/examples/state-districts.network.yaml +36 -0
- package/package.json +21 -17
- package/src/auth-grant.test.ts +54 -0
- package/src/auth-grant.ts +34 -0
- package/src/clr/validate.test.ts +65 -0
- package/src/clr/validate.ts +242 -0
- package/src/clr.ts +119 -0
- package/src/demo-inbox-refresh.test.ts +737 -0
- package/src/demo-inbox-refresh.ts +804 -0
- package/src/demo-refresh-command.test.ts +57 -0
- package/src/demo-refresh-command.ts +22 -0
- package/src/demo-refresh-ui.test.ts +66 -0
- package/src/demo-refresh-ui.ts +65 -0
- package/src/demo-refresh.test.ts +140 -0
- package/src/demo-refresh.ts +309 -0
- package/src/doctor/checks.test.ts +448 -0
- package/src/doctor/checks.ts +497 -0
- package/src/doctor.test.ts +67 -0
- package/src/doctor.ts +118 -0
- package/src/inbox.test.ts +257 -0
- package/src/inbox.ts +221 -0
- package/src/index.tsx +70 -8
- package/src/init.ts +1 -1
- package/src/open.ts +1 -1
- package/src/org/apply.test.ts +1108 -0
- package/src/org/apply.ts +924 -0
- package/src/org/branding.test.ts +60 -0
- package/src/org/diff.ts +14 -0
- package/src/org/load.ts +50 -0
- package/src/org/schema.test.ts +256 -0
- package/src/org/schema.ts +216 -0
- package/src/org.ts +124 -0
- package/src/project.test.ts +26 -1
- package/src/project.ts +105 -10
- package/src/promote.test.ts +142 -0
- package/src/promote.ts +202 -0
- package/src/refresh.test.ts +86 -0
- package/src/refresh.ts +93 -0
- package/src/send.test.ts +278 -2
- package/src/send.ts +152 -24
- package/src/setup-signing.ts +1 -1
- package/src/status.ts +2 -4
- package/src/whoami.test.ts +67 -0
- package/src/whoami.ts +129 -0
- package/tsconfig.json +1 -1
package/src/org/apply.ts
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { LCALearnCard } from '@learncard/lca-api-plugin';
|
|
5
|
+
import { ensureGitignored, parseEnv, upsertEnv, saveProject, type Project } from '../project';
|
|
6
|
+
import { setupSigning } from '../setup-signing';
|
|
7
|
+
import { out } from '../out';
|
|
8
|
+
import {
|
|
9
|
+
describeActAs,
|
|
10
|
+
getGrantActAs,
|
|
11
|
+
normalizeActAs,
|
|
12
|
+
type AuthGrantWithActAs,
|
|
13
|
+
} from '../auth-grant';
|
|
14
|
+
import { toEnvKey, type OrgBranding, type OrgServiceAccountSpec, type OrgSpec } from './schema';
|
|
15
|
+
export { toEnvKey } from './schema';
|
|
16
|
+
|
|
17
|
+
export type OrgResource =
|
|
18
|
+
| 'issuer'
|
|
19
|
+
| 'signingAuthority'
|
|
20
|
+
| 'profileManager'
|
|
21
|
+
| 'managedProfile'
|
|
22
|
+
| 'branding'
|
|
23
|
+
| 'serviceAccount'
|
|
24
|
+
| 'webhook';
|
|
25
|
+
|
|
26
|
+
export type OrgChangeAction =
|
|
27
|
+
'created' | 'updated' | 'unchanged' | 'would-create' | 'would-update' | 'drifted';
|
|
28
|
+
|
|
29
|
+
export interface OrgChange {
|
|
30
|
+
resource: OrgResource;
|
|
31
|
+
name: string;
|
|
32
|
+
action: OrgChangeAction;
|
|
33
|
+
detail?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OrgApplyResult {
|
|
37
|
+
changes: OrgChange[];
|
|
38
|
+
outputs: {
|
|
39
|
+
issuerDid: string;
|
|
40
|
+
managerDid?: string;
|
|
41
|
+
managed: Array<{ profileId: string; did: string }>;
|
|
42
|
+
serviceAccounts: Array<{ name: string; grantId: string; created: boolean }>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Managed-profile routes are manager-only on the network: the caller must
|
|
48
|
+
* authenticate as `did:web:<host>:manager:<id>`, not as the issuer. Callers
|
|
49
|
+
* supply this to open a second wallet bound to the manager DID.
|
|
50
|
+
*/
|
|
51
|
+
export type ManagerLearnCard = {
|
|
52
|
+
invoke: Pick<
|
|
53
|
+
LCALearnCard['invoke'],
|
|
54
|
+
| 'createManagedProfile'
|
|
55
|
+
| 'getManagedProfiles'
|
|
56
|
+
| 'getProfileManagerProfile'
|
|
57
|
+
| 'updateProfileManagerProfile'
|
|
58
|
+
>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type ProfileCard = {
|
|
62
|
+
invoke: Pick<LCALearnCard['invoke'], 'getProfile' | 'updateProfile'>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type ManagedSignerCard = {
|
|
66
|
+
invoke: Pick<
|
|
67
|
+
LCALearnCard['invoke'],
|
|
68
|
+
| 'getRegisteredSigningAuthorities'
|
|
69
|
+
| 'getSigningAuthorities'
|
|
70
|
+
| 'createSigningAuthority'
|
|
71
|
+
| 'registerSigningAuthority'
|
|
72
|
+
| 'setPrimaryRegisteredSigningAuthority'
|
|
73
|
+
>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface ApplyOrgOptions {
|
|
77
|
+
dryRun?: boolean;
|
|
78
|
+
secretsOut?: string;
|
|
79
|
+
/** Required when the spec has `profileManager.managed` entries. */
|
|
80
|
+
connectAsManager?: (managerDid: string) => Promise<ManagerLearnCard>;
|
|
81
|
+
/** Required when a managed profile declares `branding`; opens a wallet bound to that profile's did:web. */
|
|
82
|
+
connectAsManaged?: (managedDid: string) => Promise<ProfileCard>;
|
|
83
|
+
/**
|
|
84
|
+
* Required with a `learncard-hosted` signer and managed profiles: API tokens acting as a
|
|
85
|
+
* district have no key, so each managed profile gets its own hosted signer registered.
|
|
86
|
+
*/
|
|
87
|
+
connectAsManagedSigner?: (managedDid: string) => Promise<ManagedSignerCard>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type OrgLearnCard = {
|
|
91
|
+
id: Pick<LCALearnCard['id'], 'did'>;
|
|
92
|
+
invoke: Pick<
|
|
93
|
+
LCALearnCard['invoke'],
|
|
94
|
+
| 'getProfile'
|
|
95
|
+
| 'createProfile'
|
|
96
|
+
| 'updateProfile'
|
|
97
|
+
| 'createProfileManager'
|
|
98
|
+
| 'getAuthGrants'
|
|
99
|
+
| 'addAuthGrant'
|
|
100
|
+
| 'getAPITokenForAuthGrant'
|
|
101
|
+
| 'getRegisteredSigningAuthorities'
|
|
102
|
+
| 'registerSigningAuthority'
|
|
103
|
+
| 'setPrimaryRegisteredSigningAuthority'
|
|
104
|
+
| 'getSigningAuthorities'
|
|
105
|
+
| 'createSigningAuthority'
|
|
106
|
+
>;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Prefer the network's did:web identity; fall back to the wallet's base DID if unavailable. */
|
|
110
|
+
const resolveIssuerDid = (learnCard: OrgLearnCard): string => {
|
|
111
|
+
try {
|
|
112
|
+
return learnCard.id.did('web');
|
|
113
|
+
} catch {
|
|
114
|
+
return learnCard.id.did();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const inspectSecrets = async (secretsOut: string) => {
|
|
119
|
+
const info = await fs.lstat(secretsOut).catch((error: NodeJS.ErrnoException) => {
|
|
120
|
+
if (error.code === 'ENOENT') return undefined;
|
|
121
|
+
throw error;
|
|
122
|
+
});
|
|
123
|
+
if (info && !info.isFile())
|
|
124
|
+
throw new Error('Secrets output must be a regular file, not a symlink.');
|
|
125
|
+
return info;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const hasSecret = async (secretsOut: string, name: string): Promise<boolean> => {
|
|
129
|
+
if (!(await inspectSecrets(secretsOut))) return false;
|
|
130
|
+
const env = parseEnv(await fs.readFile(secretsOut, 'utf8'));
|
|
131
|
+
return !!env[toEnvKey(name)]?.trim();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Replace a token entry without duplicate keys, securing existing files before writing. */
|
|
135
|
+
const writeSecret = async (secretsOut: string, name: string, token: string): Promise<void> => {
|
|
136
|
+
await fs.mkdir(path.dirname(secretsOut), { recursive: true });
|
|
137
|
+
const info = await inspectSecrets(secretsOut);
|
|
138
|
+
if (info) await fs.chmod(secretsOut, 0o600);
|
|
139
|
+
const existing = info ? await fs.readFile(secretsOut, 'utf8') : '';
|
|
140
|
+
const key = toEnvKey(name);
|
|
141
|
+
const retained = existing
|
|
142
|
+
.split('\n')
|
|
143
|
+
.filter(line => line.match(/^\s*(?:export\s+)?([\w]+)\s*=/)?.[1] !== key)
|
|
144
|
+
.join('\n');
|
|
145
|
+
const next = upsertEnv(retained, { [key]: token });
|
|
146
|
+
// Like saveProject, stage the replacement so a failed write cannot destroy other tokens.
|
|
147
|
+
const temporary = `${secretsOut}.${randomUUID()}.tmp`;
|
|
148
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
149
|
+
try {
|
|
150
|
+
try {
|
|
151
|
+
await handle.writeFile(next, 'utf8');
|
|
152
|
+
} finally {
|
|
153
|
+
await handle.close();
|
|
154
|
+
}
|
|
155
|
+
await inspectSecrets(secretsOut);
|
|
156
|
+
await fs.rename(temporary, secretsOut);
|
|
157
|
+
} finally {
|
|
158
|
+
await fs.rm(temporary, { force: true });
|
|
159
|
+
}
|
|
160
|
+
await ensureGitignored(path.dirname(secretsOut), path.basename(secretsOut));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const GRANT_PAGE_SIZE = 100;
|
|
164
|
+
|
|
165
|
+
type Grant = NonNullable<Awaited<ReturnType<OrgLearnCard['invoke']['getAuthGrants']>>>[number];
|
|
166
|
+
|
|
167
|
+
/** Walk every page for this account's name so an older active grant is never missed and duplicated. */
|
|
168
|
+
const findActiveGrant = async (
|
|
169
|
+
learnCard: OrgLearnCard,
|
|
170
|
+
name: string
|
|
171
|
+
): Promise<Grant | undefined> => {
|
|
172
|
+
let cursor: string | undefined;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const page =
|
|
175
|
+
(await learnCard.invoke.getAuthGrants({
|
|
176
|
+
limit: GRANT_PAGE_SIZE,
|
|
177
|
+
cursor,
|
|
178
|
+
query: { name, status: 'active' },
|
|
179
|
+
})) ?? [];
|
|
180
|
+
const match = page.find(grant => grant.name === name && grant.status === 'active');
|
|
181
|
+
if (match) return match;
|
|
182
|
+
const last = page.at(-1);
|
|
183
|
+
if (page.length < GRANT_PAGE_SIZE || !last?.createdAt || last.createdAt === cursor)
|
|
184
|
+
return undefined;
|
|
185
|
+
cursor = last.createdAt;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Serialize applies that share a secrets file: two overlapping runs would otherwise
|
|
191
|
+
* both miss the grant, both create one, and overwrite each other's token.
|
|
192
|
+
*/
|
|
193
|
+
const withSecretsLock = async <T>(
|
|
194
|
+
secretsOut: string | undefined,
|
|
195
|
+
fn: () => Promise<T>
|
|
196
|
+
): Promise<T> => {
|
|
197
|
+
if (!secretsOut) return fn();
|
|
198
|
+
await fs.mkdir(path.dirname(secretsOut), { recursive: true });
|
|
199
|
+
const lockPath = path.join(path.dirname(secretsOut), `.${path.basename(secretsOut)}.lock`);
|
|
200
|
+
const lock = await fs.open(lockPath, 'wx', 0o600).catch((error: NodeJS.ErrnoException) => {
|
|
201
|
+
if (error.code === 'EEXIST')
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Another org apply is reconciling service accounts for ${secretsOut}. Retry when it finishes; remove a stale ${path.basename(lockPath)} only if no command is running.`
|
|
204
|
+
);
|
|
205
|
+
throw error;
|
|
206
|
+
});
|
|
207
|
+
try {
|
|
208
|
+
return await fn();
|
|
209
|
+
} finally {
|
|
210
|
+
await lock.close();
|
|
211
|
+
await fs.rm(lockPath, { force: true });
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const normalizeScope = (scope: string | undefined): string =>
|
|
216
|
+
(scope ?? '').split(/\s+/).filter(Boolean).sort().join(' ');
|
|
217
|
+
const expiryInstant = (value: string | null | undefined): number | undefined =>
|
|
218
|
+
value == null ? undefined : Date.parse(value);
|
|
219
|
+
|
|
220
|
+
type BrandingUpdate = Partial<OrgBranding> & { display?: Record<string, unknown> };
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Only fields the spec sets are compared; `display` is merged so a spec that
|
|
224
|
+
* names two colours never wipes a third one set elsewhere.
|
|
225
|
+
*/
|
|
226
|
+
export const brandingDiff = (
|
|
227
|
+
branding: OrgBranding,
|
|
228
|
+
existing: Record<string, unknown>
|
|
229
|
+
): { update: BrandingUpdate; changed: string[] } => {
|
|
230
|
+
const update: BrandingUpdate = {};
|
|
231
|
+
const changed: string[] = [];
|
|
232
|
+
const { display, ...scalars } = branding;
|
|
233
|
+
|
|
234
|
+
for (const [key, value] of Object.entries(scalars)) {
|
|
235
|
+
if (value === undefined) continue;
|
|
236
|
+
if (existing[key] !== value) {
|
|
237
|
+
(update as Record<string, unknown>)[key] = value;
|
|
238
|
+
changed.push(key);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (display) {
|
|
243
|
+
const current = (existing.display ?? {}) as Record<string, unknown>;
|
|
244
|
+
const merged = { ...current };
|
|
245
|
+
for (const [key, value] of Object.entries(display)) {
|
|
246
|
+
if (value === undefined) continue;
|
|
247
|
+
if (current[key] !== value) {
|
|
248
|
+
merged[key] = value;
|
|
249
|
+
changed.push(`display.${key}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (changed.some(name => name.startsWith('display.'))) update.display = merged;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { update, changed };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const applyBranding = async (
|
|
259
|
+
name: string,
|
|
260
|
+
branding: OrgBranding | undefined,
|
|
261
|
+
card: ProfileCard,
|
|
262
|
+
dryRun: boolean,
|
|
263
|
+
changes: OrgChange[]
|
|
264
|
+
): Promise<void> => {
|
|
265
|
+
if (!branding) return;
|
|
266
|
+
const existing = (await card.invoke.getProfile()) as Record<string, unknown> | undefined;
|
|
267
|
+
if (!existing) return;
|
|
268
|
+
const { update, changed } = brandingDiff(branding, existing);
|
|
269
|
+
if (!changed.length) {
|
|
270
|
+
changes.push({ resource: 'branding', name, action: 'unchanged' });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (dryRun) {
|
|
274
|
+
changes.push({
|
|
275
|
+
resource: 'branding',
|
|
276
|
+
name,
|
|
277
|
+
action: 'would-update',
|
|
278
|
+
detail: changed.join(', '),
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
await card.invoke.updateProfile(
|
|
283
|
+
update as Parameters<ProfileCard['invoke']['updateProfile']>[0]
|
|
284
|
+
);
|
|
285
|
+
changes.push({ resource: 'branding', name, action: 'updated', detail: changed.join(', ') });
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const applyIssuerProfile = async (
|
|
289
|
+
spec: OrgSpec,
|
|
290
|
+
learnCard: OrgLearnCard,
|
|
291
|
+
dryRun: boolean,
|
|
292
|
+
changes: OrgChange[]
|
|
293
|
+
): Promise<boolean> => {
|
|
294
|
+
const { profileId, displayName } = spec.issuer;
|
|
295
|
+
const existing = await learnCard.invoke.getProfile();
|
|
296
|
+
if (!existing) {
|
|
297
|
+
if (dryRun) {
|
|
298
|
+
changes.push({ resource: 'issuer', name: profileId, action: 'would-create' });
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
await learnCard.invoke.createProfile({ profileId, displayName, bio: '', shortBio: '' });
|
|
302
|
+
changes.push({ resource: 'issuer', name: profileId, action: 'created' });
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (existing.displayName !== displayName) {
|
|
306
|
+
if (dryRun) {
|
|
307
|
+
changes.push({
|
|
308
|
+
resource: 'issuer',
|
|
309
|
+
name: profileId,
|
|
310
|
+
action: 'would-update',
|
|
311
|
+
detail: 'displayName',
|
|
312
|
+
});
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
await learnCard.invoke.updateProfile({ displayName });
|
|
316
|
+
changes.push({
|
|
317
|
+
resource: 'issuer',
|
|
318
|
+
name: profileId,
|
|
319
|
+
action: 'updated',
|
|
320
|
+
detail: 'displayName',
|
|
321
|
+
});
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
changes.push({ resource: 'issuer', name: profileId, action: 'unchanged' });
|
|
325
|
+
return true;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// `send` reads these to pick template signing, so a matching registration must still
|
|
329
|
+
// land in .env — otherwise later sends silently fall back to the local key.
|
|
330
|
+
const isSignerSelected = (project: Project, name: string, endpoint: string): boolean =>
|
|
331
|
+
project.env.SIGNING_AUTHORITY_NAME === name &&
|
|
332
|
+
project.env.SIGNING_AUTHORITY_ENDPOINT === endpoint;
|
|
333
|
+
|
|
334
|
+
const persistSignerSelection = async (
|
|
335
|
+
project: Project,
|
|
336
|
+
name: string,
|
|
337
|
+
endpoint: string
|
|
338
|
+
): Promise<void> => {
|
|
339
|
+
if (isSignerSelected(project, name, endpoint)) return;
|
|
340
|
+
await saveProject(project, {
|
|
341
|
+
SIGNING_AUTHORITY_NAME: name,
|
|
342
|
+
SIGNING_AUTHORITY_ENDPOINT: endpoint,
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** An already-primary registration is only `unchanged` once .env also selects it. */
|
|
347
|
+
const reportSignerSelection = async (
|
|
348
|
+
project: Project,
|
|
349
|
+
name: string,
|
|
350
|
+
endpoint: string,
|
|
351
|
+
dryRun: boolean,
|
|
352
|
+
changes: OrgChange[]
|
|
353
|
+
): Promise<void> => {
|
|
354
|
+
if (isSignerSelected(project, name, endpoint)) {
|
|
355
|
+
changes.push({ resource: 'signingAuthority', name, action: 'unchanged' });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (!dryRun) await persistSignerSelection(project, name, endpoint);
|
|
359
|
+
changes.push({
|
|
360
|
+
resource: 'signingAuthority',
|
|
361
|
+
name,
|
|
362
|
+
action: dryRun ? 'would-update' : 'updated',
|
|
363
|
+
detail: 'SIGNING_AUTHORITY_NAME and SIGNING_AUTHORITY_ENDPOINT in .env',
|
|
364
|
+
});
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const applySigningAuthority = async (
|
|
368
|
+
spec: OrgSpec,
|
|
369
|
+
learnCard: OrgLearnCard,
|
|
370
|
+
project: Project,
|
|
371
|
+
dryRun: boolean,
|
|
372
|
+
changes: OrgChange[]
|
|
373
|
+
): Promise<void> => {
|
|
374
|
+
const signingAuthority = spec.issuer.signingAuthority;
|
|
375
|
+
const registered = await learnCard.invoke.getRegisteredSigningAuthorities();
|
|
376
|
+
|
|
377
|
+
if (signingAuthority.type === 'learncard-hosted') {
|
|
378
|
+
const match = registered.find(a => a.relationship.name === signingAuthority.name);
|
|
379
|
+
if (!match) {
|
|
380
|
+
if (dryRun) {
|
|
381
|
+
changes.push({
|
|
382
|
+
resource: 'signingAuthority',
|
|
383
|
+
name: signingAuthority.name,
|
|
384
|
+
action: 'would-create',
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
await setupSigning(project, learnCard, signingAuthority.name, { persist: true });
|
|
389
|
+
changes.push({
|
|
390
|
+
resource: 'signingAuthority',
|
|
391
|
+
name: signingAuthority.name,
|
|
392
|
+
action: 'created',
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!match.relationship.isPrimary) {
|
|
397
|
+
if (dryRun) {
|
|
398
|
+
changes.push({
|
|
399
|
+
resource: 'signingAuthority',
|
|
400
|
+
name: signingAuthority.name,
|
|
401
|
+
action: 'would-update',
|
|
402
|
+
detail: 'set primary',
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
await setupSigning(project, learnCard, signingAuthority.name, { persist: true });
|
|
407
|
+
changes.push({
|
|
408
|
+
resource: 'signingAuthority',
|
|
409
|
+
name: signingAuthority.name,
|
|
410
|
+
action: 'updated',
|
|
411
|
+
detail: 'set primary',
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await reportSignerSelection(
|
|
416
|
+
project,
|
|
417
|
+
signingAuthority.name,
|
|
418
|
+
match.signingAuthority.endpoint,
|
|
419
|
+
dryRun,
|
|
420
|
+
changes
|
|
421
|
+
);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { name, endpoint, did } = signingAuthority;
|
|
426
|
+
const match = registered.find(
|
|
427
|
+
a => a.relationship.name === name && a.signingAuthority.endpoint === endpoint
|
|
428
|
+
);
|
|
429
|
+
if (!match) {
|
|
430
|
+
if (dryRun) {
|
|
431
|
+
changes.push({ resource: 'signingAuthority', name, action: 'would-create' });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (!(await learnCard.invoke.registerSigningAuthority(endpoint, name, did)))
|
|
435
|
+
throw new Error(`Could not register signing authority "${name}".`);
|
|
436
|
+
if (!(await learnCard.invoke.setPrimaryRegisteredSigningAuthority(endpoint, name)))
|
|
437
|
+
throw new Error(`Could not set "${name}" as the primary signing authority.`);
|
|
438
|
+
await saveProject(project, {
|
|
439
|
+
SIGNING_AUTHORITY_NAME: name,
|
|
440
|
+
SIGNING_AUTHORITY_ENDPOINT: endpoint,
|
|
441
|
+
});
|
|
442
|
+
changes.push({ resource: 'signingAuthority', name, action: 'created' });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
// The network keys a registration by name + DID, so a rotated DID at the same
|
|
446
|
+
// endpoint cannot be updated in place; surface it instead of silently keeping the old signer.
|
|
447
|
+
if (match.relationship.did !== did) {
|
|
448
|
+
const detail = `Signing authority "${name}" is registered at ${endpoint} with DID ${match.relationship.did}, but the spec declares ${did}. Register the rotated signer under a new name, or update the spec's did to match.`;
|
|
449
|
+
if (!dryRun) throw new Error(detail);
|
|
450
|
+
changes.push({ resource: 'signingAuthority', name, action: 'drifted', detail });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (!match.relationship.isPrimary) {
|
|
454
|
+
if (dryRun) {
|
|
455
|
+
changes.push({
|
|
456
|
+
resource: 'signingAuthority',
|
|
457
|
+
name,
|
|
458
|
+
action: 'would-update',
|
|
459
|
+
detail: 'set primary',
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!(await learnCard.invoke.setPrimaryRegisteredSigningAuthority(endpoint, name)))
|
|
464
|
+
throw new Error(`Could not set "${name}" as the primary signing authority.`);
|
|
465
|
+
await persistSignerSelection(project, name, endpoint);
|
|
466
|
+
changes.push({
|
|
467
|
+
resource: 'signingAuthority',
|
|
468
|
+
name,
|
|
469
|
+
action: 'updated',
|
|
470
|
+
detail: 'set primary',
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
await reportSignerSelection(project, name, endpoint, dryRun, changes);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const applyManagedSigner = async (
|
|
478
|
+
spec: OrgSpec,
|
|
479
|
+
profileId: string,
|
|
480
|
+
managedDid: string,
|
|
481
|
+
dryRun: boolean,
|
|
482
|
+
connectAsManagedSigner: ApplyOrgOptions['connectAsManagedSigner'],
|
|
483
|
+
changes: OrgChange[]
|
|
484
|
+
): Promise<void> => {
|
|
485
|
+
const signingAuthority = spec.issuer.signingAuthority;
|
|
486
|
+
if (signingAuthority.type !== 'learncard-hosted') return;
|
|
487
|
+
const name = `${profileId}/${signingAuthority.name}`;
|
|
488
|
+
if (!connectAsManagedSigner)
|
|
489
|
+
throw new Error('Managed profiles with a hosted signer require connectAsManagedSigner.');
|
|
490
|
+
const card = await connectAsManagedSigner(managedDid);
|
|
491
|
+
const registered = (await card.invoke.getRegisteredSigningAuthorities()).find(
|
|
492
|
+
authority => authority.relationship.name === signingAuthority.name
|
|
493
|
+
);
|
|
494
|
+
if (registered?.relationship.isPrimary) {
|
|
495
|
+
changes.push({ resource: 'signingAuthority', name, action: 'unchanged' });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (dryRun) {
|
|
499
|
+
changes.push({
|
|
500
|
+
resource: 'signingAuthority',
|
|
501
|
+
name,
|
|
502
|
+
action: registered ? 'would-update' : 'would-create',
|
|
503
|
+
...(registered && { detail: 'set primary' }),
|
|
504
|
+
});
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const scratch: Project = { env: {}, envPath: '', existing: '' };
|
|
508
|
+
await setupSigning(scratch, card, signingAuthority.name, { persist: false });
|
|
509
|
+
changes.push({
|
|
510
|
+
resource: 'signingAuthority',
|
|
511
|
+
name,
|
|
512
|
+
action: registered ? 'updated' : 'created',
|
|
513
|
+
...(registered && { detail: 'set primary' }),
|
|
514
|
+
});
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const applyProfileManager = async (
|
|
518
|
+
spec: OrgSpec,
|
|
519
|
+
learnCard: OrgLearnCard,
|
|
520
|
+
project: Project,
|
|
521
|
+
dryRun: boolean,
|
|
522
|
+
connectAsManager: ApplyOrgOptions['connectAsManager'],
|
|
523
|
+
connectAsManaged: ApplyOrgOptions['connectAsManaged'],
|
|
524
|
+
connectAsManagedSigner: ApplyOrgOptions['connectAsManagedSigner'],
|
|
525
|
+
changes: OrgChange[],
|
|
526
|
+
managed: Array<{ profileId: string; did: string }>
|
|
527
|
+
): Promise<string | undefined> => {
|
|
528
|
+
if (!spec.profileManager) return undefined;
|
|
529
|
+
const { displayName, managed: managedSpecs } = spec.profileManager;
|
|
530
|
+
|
|
531
|
+
let managerDid: string | undefined;
|
|
532
|
+
let managerCard: ManagerLearnCard | undefined;
|
|
533
|
+
if (project.env.ORG_PROFILE_MANAGER_DID) {
|
|
534
|
+
managerDid = project.env.ORG_PROFILE_MANAGER_DID;
|
|
535
|
+
if (!connectAsManager)
|
|
536
|
+
throw new Error('A profile manager requires a manager connection (connectAsManager).');
|
|
537
|
+
managerCard = await connectAsManager(managerDid);
|
|
538
|
+
const existingManager = await managerCard.invoke.getProfileManagerProfile();
|
|
539
|
+
if (existingManager && existingManager.displayName !== displayName) {
|
|
540
|
+
if (!dryRun) await managerCard.invoke.updateProfileManagerProfile({ displayName });
|
|
541
|
+
changes.push({
|
|
542
|
+
resource: 'profileManager',
|
|
543
|
+
name: displayName,
|
|
544
|
+
action: dryRun ? 'would-update' : 'updated',
|
|
545
|
+
detail: 'displayName',
|
|
546
|
+
});
|
|
547
|
+
} else {
|
|
548
|
+
changes.push({ resource: 'profileManager', name: displayName, action: 'unchanged' });
|
|
549
|
+
}
|
|
550
|
+
} else if (dryRun) {
|
|
551
|
+
changes.push({ resource: 'profileManager', name: displayName, action: 'would-create' });
|
|
552
|
+
} else {
|
|
553
|
+
managerDid = await learnCard.invoke.createProfileManager({ displayName });
|
|
554
|
+
await saveProject(project, { ORG_PROFILE_MANAGER_DID: managerDid });
|
|
555
|
+
changes.push({ resource: 'profileManager', name: displayName, action: 'created' });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!managedSpecs.length) return managerDid;
|
|
559
|
+
|
|
560
|
+
if (!managerDid) {
|
|
561
|
+
for (const managedSpec of managedSpecs) {
|
|
562
|
+
changes.push({
|
|
563
|
+
resource: 'managedProfile',
|
|
564
|
+
name: managedSpec.profileId,
|
|
565
|
+
action: 'would-create',
|
|
566
|
+
});
|
|
567
|
+
if (spec.issuer.signingAuthority.type === 'learncard-hosted')
|
|
568
|
+
changes.push({
|
|
569
|
+
resource: 'signingAuthority',
|
|
570
|
+
name: `${managedSpec.profileId}/${spec.issuer.signingAuthority.name}`,
|
|
571
|
+
action: 'would-create',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return managerDid;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!connectAsManager)
|
|
578
|
+
throw new Error('Managed profiles require a manager connection (connectAsManager).');
|
|
579
|
+
managerCard ??= await connectAsManager(managerDid);
|
|
580
|
+
|
|
581
|
+
const existingManaged = new Map<string, { did: string; displayName?: string }>();
|
|
582
|
+
let cursor: string | undefined;
|
|
583
|
+
let hasMore = true;
|
|
584
|
+
while (hasMore) {
|
|
585
|
+
const page = await managerCard.invoke.getManagedProfiles({ limit: 100, cursor });
|
|
586
|
+
for (const profile of page.records)
|
|
587
|
+
existingManaged.set(profile.profileId, {
|
|
588
|
+
did: profile.did,
|
|
589
|
+
displayName: profile.displayName,
|
|
590
|
+
});
|
|
591
|
+
hasMore = page.hasMore;
|
|
592
|
+
cursor = page.cursor;
|
|
593
|
+
if (hasMore && !cursor) break;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
for (const managedSpec of managedSpecs) {
|
|
597
|
+
const existing = existingManaged.get(managedSpec.profileId);
|
|
598
|
+
if (existing) {
|
|
599
|
+
managed.push({ profileId: managedSpec.profileId, did: existing.did });
|
|
600
|
+
const needsRename = existing.displayName !== managedSpec.displayName;
|
|
601
|
+
if ((needsRename || managedSpec.branding) && !connectAsManaged)
|
|
602
|
+
throw new Error(
|
|
603
|
+
'Updating a managed profile (displayName or branding) requires connectAsManaged.'
|
|
604
|
+
);
|
|
605
|
+
const openManaged = () => connectAsManaged!(existing.did);
|
|
606
|
+
if (needsRename) {
|
|
607
|
+
if (!dryRun) {
|
|
608
|
+
const card = await openManaged();
|
|
609
|
+
await card.invoke.updateProfile({ displayName: managedSpec.displayName });
|
|
610
|
+
}
|
|
611
|
+
changes.push({
|
|
612
|
+
resource: 'managedProfile',
|
|
613
|
+
name: managedSpec.profileId,
|
|
614
|
+
action: dryRun ? 'would-update' : 'updated',
|
|
615
|
+
detail: 'displayName',
|
|
616
|
+
});
|
|
617
|
+
} else {
|
|
618
|
+
changes.push({
|
|
619
|
+
resource: 'managedProfile',
|
|
620
|
+
name: managedSpec.profileId,
|
|
621
|
+
action: 'unchanged',
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (managedSpec.branding) {
|
|
625
|
+
await applyBranding(
|
|
626
|
+
managedSpec.profileId,
|
|
627
|
+
managedSpec.branding,
|
|
628
|
+
await openManaged(),
|
|
629
|
+
dryRun,
|
|
630
|
+
changes
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
await applyManagedSigner(
|
|
634
|
+
spec,
|
|
635
|
+
managedSpec.profileId,
|
|
636
|
+
existing.did,
|
|
637
|
+
dryRun,
|
|
638
|
+
connectAsManagedSigner,
|
|
639
|
+
changes
|
|
640
|
+
);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (dryRun) {
|
|
644
|
+
changes.push({
|
|
645
|
+
resource: 'managedProfile',
|
|
646
|
+
name: managedSpec.profileId,
|
|
647
|
+
action: 'would-create',
|
|
648
|
+
});
|
|
649
|
+
if (spec.issuer.signingAuthority.type === 'learncard-hosted')
|
|
650
|
+
changes.push({
|
|
651
|
+
resource: 'signingAuthority',
|
|
652
|
+
name: `${managedSpec.profileId}/${spec.issuer.signingAuthority.name}`,
|
|
653
|
+
action: 'would-create',
|
|
654
|
+
});
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const { display, ...brandingScalars } = managedSpec.branding ?? {};
|
|
658
|
+
const newDid = await managerCard.invoke.createManagedProfile({
|
|
659
|
+
profileId: managedSpec.profileId,
|
|
660
|
+
displayName: managedSpec.displayName,
|
|
661
|
+
bio: '',
|
|
662
|
+
shortBio: '',
|
|
663
|
+
...brandingScalars,
|
|
664
|
+
...(display && { display }),
|
|
665
|
+
});
|
|
666
|
+
managed.push({ profileId: managedSpec.profileId, did: newDid });
|
|
667
|
+
changes.push({
|
|
668
|
+
resource: 'managedProfile',
|
|
669
|
+
name: managedSpec.profileId,
|
|
670
|
+
action: 'created',
|
|
671
|
+
});
|
|
672
|
+
await applyManagedSigner(
|
|
673
|
+
spec,
|
|
674
|
+
managedSpec.profileId,
|
|
675
|
+
newDid,
|
|
676
|
+
false,
|
|
677
|
+
connectAsManagedSigner,
|
|
678
|
+
changes
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return managerDid;
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
/** `'*'` stays `'*'`; a profileId list joins with `,` to match the grant's flat `actAs` string. */
|
|
686
|
+
const actAsValue = (actAs: OrgServiceAccountSpec['actAs']): string | undefined =>
|
|
687
|
+
actAs === undefined ? undefined : actAs === '*' ? '*' : actAs.join(',');
|
|
688
|
+
|
|
689
|
+
const applyServiceAccounts = async (
|
|
690
|
+
spec: OrgSpec,
|
|
691
|
+
learnCard: OrgLearnCard,
|
|
692
|
+
dryRun: boolean,
|
|
693
|
+
secretsOut: string | undefined,
|
|
694
|
+
changes: OrgChange[],
|
|
695
|
+
serviceAccounts: Array<{ name: string; grantId: string; created: boolean }>
|
|
696
|
+
): Promise<void> => {
|
|
697
|
+
if (!spec.serviceAccounts?.length) return;
|
|
698
|
+
|
|
699
|
+
await withSecretsLock(dryRun ? undefined : secretsOut, async () => {
|
|
700
|
+
for (const account of spec.serviceAccounts ?? []) {
|
|
701
|
+
const actAs = actAsValue(account.actAs);
|
|
702
|
+
const existing = await findActiveGrant(learnCard, account.name);
|
|
703
|
+
if (existing) {
|
|
704
|
+
if (!existing.id)
|
|
705
|
+
throw new Error(
|
|
706
|
+
`Service account "${account.name}" returned a grant without an ID; cannot safely reconcile it.`
|
|
707
|
+
);
|
|
708
|
+
serviceAccounts.push({
|
|
709
|
+
name: account.name,
|
|
710
|
+
grantId: existing.id,
|
|
711
|
+
created: false,
|
|
712
|
+
});
|
|
713
|
+
// scope, expiresAt and actAs are all fixed when the token is minted — the only
|
|
714
|
+
// way to change them is to revoke and mint a replacement.
|
|
715
|
+
const drift = [
|
|
716
|
+
normalizeScope(existing.scope) !== normalizeScope(account.scopes.join(' ')) &&
|
|
717
|
+
'scope',
|
|
718
|
+
expiryInstant(existing.expiresAt) !== expiryInstant(account.expiresAt) &&
|
|
719
|
+
'expiresAt',
|
|
720
|
+
normalizeActAs(getGrantActAs(existing)) !== normalizeActAs(actAs) &&
|
|
721
|
+
`actAs ${describeActAs(getGrantActAs(existing))} -> ${describeActAs(actAs)}`,
|
|
722
|
+
].filter(Boolean);
|
|
723
|
+
if (drift.length) {
|
|
724
|
+
const detail = `Service account "${account.name}" grant has drifted (${drift.join(', ')}). These are fixed when the token is minted — revoke it (npx @learncard/cli token --revoke ${existing.id}) and re-run org apply with --secrets-out to mint a replacement.`;
|
|
725
|
+
if (!dryRun) throw new Error(detail);
|
|
726
|
+
changes.push({
|
|
727
|
+
resource: 'serviceAccount',
|
|
728
|
+
name: account.name,
|
|
729
|
+
action: 'drifted',
|
|
730
|
+
detail,
|
|
731
|
+
});
|
|
732
|
+
} else if (secretsOut && !(await hasSecret(secretsOut, account.name))) {
|
|
733
|
+
if (!dryRun) {
|
|
734
|
+
const token = await learnCard.invoke.getAPITokenForAuthGrant(existing.id);
|
|
735
|
+
await writeSecret(secretsOut, account.name, token);
|
|
736
|
+
}
|
|
737
|
+
changes.push({
|
|
738
|
+
resource: 'serviceAccount',
|
|
739
|
+
name: account.name,
|
|
740
|
+
action: dryRun ? 'would-update' : 'updated',
|
|
741
|
+
detail: dryRun ? 'token would be re-issued' : 'token re-issued',
|
|
742
|
+
});
|
|
743
|
+
} else {
|
|
744
|
+
changes.push({
|
|
745
|
+
resource: 'serviceAccount',
|
|
746
|
+
name: account.name,
|
|
747
|
+
action: 'unchanged',
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (dryRun) {
|
|
753
|
+
changes.push({
|
|
754
|
+
resource: 'serviceAccount',
|
|
755
|
+
name: account.name,
|
|
756
|
+
action: 'would-create',
|
|
757
|
+
});
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (!secretsOut)
|
|
761
|
+
throw new Error(
|
|
762
|
+
`Pass --secrets-out ./secrets.env (any path; keep it beside .env and out of git) to create service account "${account.name}" — the token is written to this file and not stored elsewhere by the CLI.`
|
|
763
|
+
);
|
|
764
|
+
const payload: AuthGrantWithActAs = {
|
|
765
|
+
name: account.name,
|
|
766
|
+
scope: account.scopes.join(' '),
|
|
767
|
+
...(account.expiresAt
|
|
768
|
+
? { expiresAt: new Date(account.expiresAt).toISOString() }
|
|
769
|
+
: {}),
|
|
770
|
+
...(actAs !== undefined ? { actAs } : {}),
|
|
771
|
+
};
|
|
772
|
+
const grantId = await learnCard.invoke.addAuthGrant(payload);
|
|
773
|
+
const token = await learnCard.invoke.getAPITokenForAuthGrant(grantId);
|
|
774
|
+
await writeSecret(secretsOut, account.name, token);
|
|
775
|
+
out.log(`Token for "${account.name}" written to ${secretsOut}`);
|
|
776
|
+
serviceAccounts.push({ name: account.name, grantId, created: true });
|
|
777
|
+
changes.push({ resource: 'serviceAccount', name: account.name, action: 'created' });
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const applyWebhooks = async (
|
|
783
|
+
spec: OrgSpec,
|
|
784
|
+
project: Project,
|
|
785
|
+
dryRun: boolean,
|
|
786
|
+
changes: OrgChange[]
|
|
787
|
+
): Promise<void> => {
|
|
788
|
+
const [primary, ...extra] = spec.webhooks ?? [];
|
|
789
|
+
if (!primary) return;
|
|
790
|
+
|
|
791
|
+
const current = project.env.WEBHOOK_URL;
|
|
792
|
+
if (current === primary.url) {
|
|
793
|
+
changes.push({ resource: 'webhook', name: primary.url, action: 'unchanged' });
|
|
794
|
+
} else {
|
|
795
|
+
const action = current ? 'updated' : 'created';
|
|
796
|
+
if (dryRun) {
|
|
797
|
+
changes.push({
|
|
798
|
+
resource: 'webhook',
|
|
799
|
+
name: primary.url,
|
|
800
|
+
action: current ? 'would-update' : 'would-create',
|
|
801
|
+
detail: 'WEBHOOK_URL in .env',
|
|
802
|
+
});
|
|
803
|
+
} else {
|
|
804
|
+
await saveProject(project, { WEBHOOK_URL: primary.url });
|
|
805
|
+
changes.push({
|
|
806
|
+
resource: 'webhook',
|
|
807
|
+
name: primary.url,
|
|
808
|
+
action,
|
|
809
|
+
detail: 'set WEBHOOK_URL in .env; pass it as configuration.webhookUrl on each inbox issue',
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
for (const webhook of extra) {
|
|
815
|
+
changes.push({
|
|
816
|
+
resource: 'webhook',
|
|
817
|
+
name: webhook.url,
|
|
818
|
+
action: 'unchanged',
|
|
819
|
+
detail: 'only the first webhook becomes WEBHOOK_URL; use this one per-issuance',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const planFreshOrg = (spec: OrgSpec, project: Project, changes: OrgChange[]): void => {
|
|
825
|
+
const note = 'after the issuer profile is created';
|
|
826
|
+
changes.push({
|
|
827
|
+
resource: 'signingAuthority',
|
|
828
|
+
name: spec.issuer.signingAuthority.name,
|
|
829
|
+
action: 'would-create',
|
|
830
|
+
detail: note,
|
|
831
|
+
});
|
|
832
|
+
if (spec.issuer.branding)
|
|
833
|
+
changes.push({
|
|
834
|
+
resource: 'branding',
|
|
835
|
+
name: spec.issuer.profileId,
|
|
836
|
+
action: 'would-update',
|
|
837
|
+
detail: note,
|
|
838
|
+
});
|
|
839
|
+
if (spec.profileManager) {
|
|
840
|
+
changes.push({
|
|
841
|
+
resource: 'profileManager',
|
|
842
|
+
name: spec.profileManager.displayName,
|
|
843
|
+
action: 'would-create',
|
|
844
|
+
detail: note,
|
|
845
|
+
});
|
|
846
|
+
for (const entry of spec.profileManager.managed ?? []) {
|
|
847
|
+
changes.push({
|
|
848
|
+
resource: 'managedProfile',
|
|
849
|
+
name: entry.profileId,
|
|
850
|
+
action: 'would-create',
|
|
851
|
+
detail: note,
|
|
852
|
+
});
|
|
853
|
+
if (spec.issuer.signingAuthority.type === 'learncard-hosted')
|
|
854
|
+
changes.push({
|
|
855
|
+
resource: 'signingAuthority',
|
|
856
|
+
name: `${entry.profileId}/${spec.issuer.signingAuthority.name}`,
|
|
857
|
+
action: 'would-create',
|
|
858
|
+
detail: note,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
for (const account of spec.serviceAccounts ?? [])
|
|
863
|
+
changes.push({
|
|
864
|
+
resource: 'serviceAccount',
|
|
865
|
+
name: account.name,
|
|
866
|
+
action: 'would-create',
|
|
867
|
+
detail: note,
|
|
868
|
+
});
|
|
869
|
+
const [primary, ...extra] = spec.webhooks ?? [];
|
|
870
|
+
if (primary)
|
|
871
|
+
changes.push({
|
|
872
|
+
resource: 'webhook',
|
|
873
|
+
name: primary.url,
|
|
874
|
+
action: project.env.WEBHOOK_URL === primary.url ? 'unchanged' : 'would-create',
|
|
875
|
+
detail: 'WEBHOOK_URL in .env',
|
|
876
|
+
});
|
|
877
|
+
for (const webhook of extra)
|
|
878
|
+
changes.push({ resource: 'webhook', name: webhook.url, action: 'unchanged' });
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
export const applyOrg = async (
|
|
882
|
+
spec: OrgSpec,
|
|
883
|
+
learnCard: OrgLearnCard,
|
|
884
|
+
project: Project,
|
|
885
|
+
opts: ApplyOrgOptions = {}
|
|
886
|
+
): Promise<OrgApplyResult> => {
|
|
887
|
+
const dryRun = !!opts.dryRun;
|
|
888
|
+
const changes: OrgChange[] = [];
|
|
889
|
+
const managed: Array<{ profileId: string; did: string }> = [];
|
|
890
|
+
const serviceAccounts: Array<{ name: string; grantId: string; created: boolean }> = [];
|
|
891
|
+
|
|
892
|
+
const issuerExists = await applyIssuerProfile(spec, learnCard, dryRun, changes);
|
|
893
|
+
const issuerDid = resolveIssuerDid(learnCard);
|
|
894
|
+
|
|
895
|
+
if (!issuerExists) {
|
|
896
|
+
planFreshOrg(spec, project, changes);
|
|
897
|
+
return {
|
|
898
|
+
changes,
|
|
899
|
+
outputs: { issuerDid, managerDid: undefined, managed: [], serviceAccounts: [] },
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
await applyBranding(spec.issuer.profileId, spec.issuer.branding, learnCard, dryRun, changes);
|
|
904
|
+
|
|
905
|
+
await applySigningAuthority(spec, learnCard, project, dryRun, changes);
|
|
906
|
+
|
|
907
|
+
const managerDid = await applyProfileManager(
|
|
908
|
+
spec,
|
|
909
|
+
learnCard,
|
|
910
|
+
project,
|
|
911
|
+
dryRun,
|
|
912
|
+
opts.connectAsManager,
|
|
913
|
+
opts.connectAsManaged,
|
|
914
|
+
opts.connectAsManagedSigner,
|
|
915
|
+
changes,
|
|
916
|
+
managed
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
await applyServiceAccounts(spec, learnCard, dryRun, opts.secretsOut, changes, serviceAccounts);
|
|
920
|
+
|
|
921
|
+
await applyWebhooks(spec, project, opts.dryRun ?? false, changes);
|
|
922
|
+
|
|
923
|
+
return { changes, outputs: { issuerDid, managerDid, managed, serviceAccounts } };
|
|
924
|
+
};
|