@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { brandingDiff } from './apply';
|
|
3
|
+
import { brandingSchema } from './schema';
|
|
4
|
+
|
|
5
|
+
describe('brandingSchema', () => {
|
|
6
|
+
it('accepts https images and hex colours', () => {
|
|
7
|
+
expect(
|
|
8
|
+
brandingSchema.safeParse({
|
|
9
|
+
image: 'https://cdn.example.org/logo.png',
|
|
10
|
+
display: { accentColor: '#2E7D32' },
|
|
11
|
+
type: 'organization',
|
|
12
|
+
}).success
|
|
13
|
+
).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects a local path with a hint about hosting the image', () => {
|
|
17
|
+
const result = brandingSchema.safeParse({ image: './assets/logo.png' });
|
|
18
|
+
expect(result.success).toBe(false);
|
|
19
|
+
expect(JSON.stringify(result.error?.issues)).toContain('not uploaded yet');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects non-hex colours and unknown display keys', () => {
|
|
23
|
+
expect(brandingSchema.safeParse({ display: { accentColor: 'green' } }).success).toBe(false);
|
|
24
|
+
expect(brandingSchema.safeParse({ display: { borderColor: '#000000' } }).success).toBe(
|
|
25
|
+
false
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('brandingDiff', () => {
|
|
31
|
+
const existing = {
|
|
32
|
+
image: 'https://cdn.example.org/old.png',
|
|
33
|
+
websiteLink: 'https://ed.example.gov',
|
|
34
|
+
display: { backgroundColor: '#111111', fontColor: '#FFFFFF' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
it('reports nothing when every spec field already matches', () => {
|
|
38
|
+
const { changed } = brandingDiff(
|
|
39
|
+
{ websiteLink: 'https://ed.example.gov', display: { fontColor: '#FFFFFF' } },
|
|
40
|
+
existing
|
|
41
|
+
);
|
|
42
|
+
expect(changed).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('only sends changed scalars and merges display instead of replacing it', () => {
|
|
46
|
+
const { update, changed } = brandingDiff(
|
|
47
|
+
{
|
|
48
|
+
image: 'https://cdn.example.org/new.png',
|
|
49
|
+
websiteLink: 'https://ed.example.gov',
|
|
50
|
+
display: { accentColor: '#2E7D32' },
|
|
51
|
+
},
|
|
52
|
+
existing
|
|
53
|
+
);
|
|
54
|
+
expect(changed).toEqual(['image', 'display.accentColor']);
|
|
55
|
+
expect(update).toEqual({
|
|
56
|
+
image: 'https://cdn.example.org/new.png',
|
|
57
|
+
display: { backgroundColor: '#111111', fontColor: '#FFFFFF', accentColor: '#2E7D32' },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/org/diff.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OrgChange } from './apply';
|
|
2
|
+
|
|
3
|
+
export const hasChanges = (changes: OrgChange[]): boolean =>
|
|
4
|
+
changes.some(change => change.action !== 'unchanged');
|
|
5
|
+
|
|
6
|
+
export const formatChanges = (changes: OrgChange[]): string[] => {
|
|
7
|
+
if (!changes.length) return [];
|
|
8
|
+
const resourceWidth = Math.max(...changes.map(change => change.resource.length));
|
|
9
|
+
const nameWidth = Math.max(...changes.map(change => change.name.length));
|
|
10
|
+
return changes.map(change => {
|
|
11
|
+
const detail = change.detail ? ` (${change.detail})` : '';
|
|
12
|
+
return `${change.resource.padEnd(resourceWidth)} ${change.name.padEnd(nameWidth)} ${change.action}${detail}`;
|
|
13
|
+
});
|
|
14
|
+
};
|
package/src/org/load.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { OrgSpecValidator, type OrgSpec } from './schema';
|
|
6
|
+
import { out } from '../out';
|
|
7
|
+
|
|
8
|
+
const SUPPORTED_EXTENSIONS = ['.yaml', '.yml', '.json'];
|
|
9
|
+
|
|
10
|
+
export const formatIssues = (error: z.ZodError): string[] =>
|
|
11
|
+
error.issues.map(
|
|
12
|
+
issue =>
|
|
13
|
+
`${issue.path.length ? issue.path.map(String).join('.') : '(root)'}: ${issue.message}`
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export const loadOrgSpec = async (file: string): Promise<OrgSpec> => {
|
|
17
|
+
const absolute = path.resolve(file);
|
|
18
|
+
const ext = path.extname(absolute).toLowerCase();
|
|
19
|
+
if (!SUPPORTED_EXTENSIONS.includes(ext))
|
|
20
|
+
throw new Error(`Unsupported file type "${ext || file}". Use .yaml, .yml, or .json.`);
|
|
21
|
+
|
|
22
|
+
let text: string;
|
|
23
|
+
try {
|
|
24
|
+
text = await fs.readFile(absolute, 'utf8');
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
27
|
+
throw new Error(`Could not find ${file}.`, { cause: error });
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let parsed: unknown;
|
|
32
|
+
try {
|
|
33
|
+
parsed = ext === '.json' ? JSON.parse(text) : parseYaml(text);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Could not parse ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
37
|
+
{ cause: error }
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = OrgSpecValidator.safeParse(parsed);
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
const issues = formatIssues(result.error);
|
|
44
|
+
for (const issue of issues) out.log(` ${issue}`);
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Invalid org spec in ${file}: ${issues.length} issue${issues.length === 1 ? '' : 's'} (${issues.join('; ')})`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return result.data;
|
|
50
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { OrgSpecValidator } from './schema';
|
|
3
|
+
|
|
4
|
+
const validSpec = {
|
|
5
|
+
issuer: {
|
|
6
|
+
profileId: 'scde',
|
|
7
|
+
displayName: 'South Carolina Department of Education',
|
|
8
|
+
signingAuthority: { type: 'learncard-hosted', name: 'scde-clr' },
|
|
9
|
+
},
|
|
10
|
+
profileManager: {
|
|
11
|
+
displayName: 'SC Districts',
|
|
12
|
+
managed: [{ profileId: 'sc-greenville', displayName: 'Greenville County Schools' }],
|
|
13
|
+
},
|
|
14
|
+
serviceAccounts: [
|
|
15
|
+
{
|
|
16
|
+
name: 'ea-clr-issuer',
|
|
17
|
+
scopes: ['inbox:write', 'inbox:read', 'credentials:write', 'credentials:read'],
|
|
18
|
+
expiresAt: '2027-06-30',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
webhooks: [{ url: 'https://clr.example.org/learncard/webhook' }],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('OrgSpecValidator', () => {
|
|
25
|
+
it.each([
|
|
26
|
+
['read-only', 'READ_ONLY'],
|
|
27
|
+
['issuer', 'issuer'],
|
|
28
|
+
['Issuer', 'issuer'],
|
|
29
|
+
])('rejects duplicate normalized secrets keys: %s / %s', (first, second) => {
|
|
30
|
+
const result = OrgSpecValidator.safeParse({
|
|
31
|
+
...validSpec,
|
|
32
|
+
serviceAccounts: [first, second].map(name => ({ name, scopes: ['inbox:read'] })),
|
|
33
|
+
});
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
if (!result.success)
|
|
36
|
+
expect(result.error.issues).toContainEqual(
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
path: ['serviceAccounts', 1, 'name'],
|
|
39
|
+
message: expect.stringContaining('Duplicate service-account secrets key'),
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accepts distinct normalized secrets keys', () => {
|
|
45
|
+
expect(
|
|
46
|
+
OrgSpecValidator.safeParse({
|
|
47
|
+
...validSpec,
|
|
48
|
+
serviceAccounts: ['read-only', 'write-only'].map(name => ({
|
|
49
|
+
name,
|
|
50
|
+
scopes: ['inbox:read'],
|
|
51
|
+
})),
|
|
52
|
+
}).success
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('accepts a fully-populated spec', () => {
|
|
57
|
+
const result = OrgSpecValidator.safeParse(validSpec);
|
|
58
|
+
expect(result.success).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('accepts the minimal issuer-only spec', () => {
|
|
62
|
+
const result = OrgSpecValidator.safeParse({ issuer: validSpec.issuer });
|
|
63
|
+
expect(result.success).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects a self-hosted signing authority missing endpoint/did', () => {
|
|
67
|
+
const result = OrgSpecValidator.safeParse({
|
|
68
|
+
issuer: {
|
|
69
|
+
...validSpec.issuer,
|
|
70
|
+
signingAuthority: { type: 'self-hosted', name: 'my-issuer' },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(result.success).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('accepts a valid self-hosted signing authority', () => {
|
|
77
|
+
const result = OrgSpecValidator.safeParse({
|
|
78
|
+
issuer: {
|
|
79
|
+
...validSpec.issuer,
|
|
80
|
+
signingAuthority: {
|
|
81
|
+
type: 'self-hosted',
|
|
82
|
+
name: 'my-issuer',
|
|
83
|
+
endpoint: 'https://issuer.example/api',
|
|
84
|
+
did: 'did:web:issuer.example',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(result.success).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects an unknown scope resource', () => {
|
|
92
|
+
const result = OrgSpecValidator.safeParse({
|
|
93
|
+
...validSpec,
|
|
94
|
+
serviceAccounts: [{ name: 'ea-clr-issuer', scopes: ['unicorns:write'] }],
|
|
95
|
+
});
|
|
96
|
+
expect(result.success).toBe(false);
|
|
97
|
+
if (!result.success)
|
|
98
|
+
expect(
|
|
99
|
+
result.error.issues.some(issue => issue.message.includes('Unknown scope resource'))
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects a malformed scope action', () => {
|
|
104
|
+
const result = OrgSpecValidator.safeParse({
|
|
105
|
+
...validSpec,
|
|
106
|
+
serviceAccounts: [{ name: 'ea-clr-issuer', scopes: ['boosts:admin'] }],
|
|
107
|
+
});
|
|
108
|
+
expect(result.success).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects a signing authority name that is too long', () => {
|
|
112
|
+
const result = OrgSpecValidator.safeParse({
|
|
113
|
+
issuer: {
|
|
114
|
+
...validSpec.issuer,
|
|
115
|
+
signingAuthority: { type: 'learncard-hosted', name: 'this-name-is-way-too-long' },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
expect(result.success).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects a signing authority name with uppercase or invalid characters', () => {
|
|
122
|
+
const result = OrgSpecValidator.safeParse({
|
|
123
|
+
issuer: {
|
|
124
|
+
...validSpec.issuer,
|
|
125
|
+
signingAuthority: { type: 'learncard-hosted', name: 'SCDE_CLR' },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
expect(result.success).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rejects a profileId that is too short', () => {
|
|
132
|
+
const result = OrgSpecValidator.safeParse({
|
|
133
|
+
issuer: { ...validSpec.issuer, profileId: 'ab' },
|
|
134
|
+
});
|
|
135
|
+
expect(result.success).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects a profileId with uppercase or invalid characters', () => {
|
|
139
|
+
const result = OrgSpecValidator.safeParse({
|
|
140
|
+
issuer: { ...validSpec.issuer, profileId: 'SCDE_Org!' },
|
|
141
|
+
});
|
|
142
|
+
expect(result.success).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('rejects a webhook URL that is not https', () => {
|
|
146
|
+
const result = OrgSpecValidator.safeParse({
|
|
147
|
+
...validSpec,
|
|
148
|
+
webhooks: [{ url: 'http://clr.example.org/webhook' }],
|
|
149
|
+
});
|
|
150
|
+
expect(result.success).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('defaults profileManager.managed to an empty array when omitted', () => {
|
|
154
|
+
const result = OrgSpecValidator.safeParse({
|
|
155
|
+
issuer: validSpec.issuer,
|
|
156
|
+
profileManager: { displayName: 'SC Districts' },
|
|
157
|
+
});
|
|
158
|
+
expect(result.success).toBe(true);
|
|
159
|
+
if (result.success) expect(result.data.profileManager?.managed).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.each([
|
|
163
|
+
[
|
|
164
|
+
'profileManager.managedProfiles',
|
|
165
|
+
{ profileManager: { displayName: 'SC Districts', managedProfiles: [] } },
|
|
166
|
+
['profileManager'],
|
|
167
|
+
],
|
|
168
|
+
[
|
|
169
|
+
'issuer.signingAuthority.endpoint on a hosted signer',
|
|
170
|
+
{
|
|
171
|
+
issuer: {
|
|
172
|
+
...validSpec.issuer,
|
|
173
|
+
signingAuthority: {
|
|
174
|
+
type: 'learncard-hosted',
|
|
175
|
+
name: 'scde-clr',
|
|
176
|
+
endpoint: 'https://x',
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
['issuer', 'signingAuthority'],
|
|
181
|
+
],
|
|
182
|
+
[
|
|
183
|
+
'serviceAccounts[].scope',
|
|
184
|
+
{ serviceAccounts: [{ name: 'a', scope: ['inbox:read'] }] },
|
|
185
|
+
['serviceAccounts', 0],
|
|
186
|
+
],
|
|
187
|
+
['top-level typo', { webhook: [{ url: 'https://x' }] }, []],
|
|
188
|
+
])('rejects unknown keys instead of silently dropping them: %s', (_label, override, path) => {
|
|
189
|
+
const result = OrgSpecValidator.safeParse({ ...validSpec, ...override });
|
|
190
|
+
expect(result.success).toBe(false);
|
|
191
|
+
if (!result.success)
|
|
192
|
+
expect(result.error.issues).toContainEqual(
|
|
193
|
+
expect.objectContaining({ code: 'unrecognized_keys', path })
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('serviceAccounts[].actAs', () => {
|
|
199
|
+
it('accepts "*" when a profileManager is present', () => {
|
|
200
|
+
const result = OrgSpecValidator.safeParse({
|
|
201
|
+
...validSpec,
|
|
202
|
+
serviceAccounts: [{ ...validSpec.serviceAccounts[0], actAs: '*' }],
|
|
203
|
+
});
|
|
204
|
+
expect(result.success).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('accepts a list of profileIds that are all managed', () => {
|
|
208
|
+
const result = OrgSpecValidator.safeParse({
|
|
209
|
+
...validSpec,
|
|
210
|
+
serviceAccounts: [{ ...validSpec.serviceAccounts[0], actAs: ['sc-greenville'] }],
|
|
211
|
+
});
|
|
212
|
+
expect(result.success).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('rejects a list naming a profileId that is not managed', () => {
|
|
216
|
+
const result = OrgSpecValidator.safeParse({
|
|
217
|
+
...validSpec,
|
|
218
|
+
serviceAccounts: [
|
|
219
|
+
{ ...validSpec.serviceAccounts[0], actAs: ['sc-greenville', 'sc-north'] },
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
expect(result.success).toBe(false);
|
|
223
|
+
if (!result.success) {
|
|
224
|
+
const issue = result.error.issues.find(i => i.message.includes('sc-north'));
|
|
225
|
+
expect(issue?.message).toBe('"sc-north" is not a managed profile in this spec');
|
|
226
|
+
expect(issue?.path).toEqual(['serviceAccounts', 0, 'actAs']);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('rejects "*" when there is no profileManager', () => {
|
|
231
|
+
const result = OrgSpecValidator.safeParse({
|
|
232
|
+
issuer: validSpec.issuer,
|
|
233
|
+
serviceAccounts: [{ ...validSpec.serviceAccounts[0], actAs: '*' }],
|
|
234
|
+
});
|
|
235
|
+
expect(result.success).toBe(false);
|
|
236
|
+
if (!result.success)
|
|
237
|
+
expect(
|
|
238
|
+
result.error.issues.some(issue => issue.message.includes('profileManager'))
|
|
239
|
+
).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('examples/*.network.yaml', () => {
|
|
244
|
+
it('every shipped example parses', async () => {
|
|
245
|
+
const fs = await import('node:fs/promises');
|
|
246
|
+
const path = await import('node:path');
|
|
247
|
+
const { loadOrgSpec } = await import('./load');
|
|
248
|
+
const dir = path.resolve(__dirname, '../../examples');
|
|
249
|
+
const files = (await fs.readdir(dir)).filter(f => f.endsWith('.network.yaml'));
|
|
250
|
+
expect(files.length).toBeGreaterThanOrEqual(5);
|
|
251
|
+
for (const file of files) {
|
|
252
|
+
const spec = await loadOrgSpec(path.join(dir, file));
|
|
253
|
+
expect(spec.issuer.profileId, file).toBeTruthy();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { validateScope } from '../token';
|
|
3
|
+
|
|
4
|
+
// Server constraint: `LCNProfileValidator.profileId` (packages/learn-card-types/src/lcn.ts).
|
|
5
|
+
const PROFILE_ID_PATTERN = /^[a-z0-9-]{3,40}$/;
|
|
6
|
+
// Server constraint: `LCNSigningAuthorityForUserValidator.relationship.name`.
|
|
7
|
+
const SIGNING_AUTHORITY_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
8
|
+
|
|
9
|
+
const profileIdSchema = z
|
|
10
|
+
.string()
|
|
11
|
+
.regex(PROFILE_ID_PATTERN, 'must be 3-40 characters: lowercase letters, numbers, and hyphens');
|
|
12
|
+
|
|
13
|
+
const displayNameSchema = z.string().min(1, 'is required');
|
|
14
|
+
|
|
15
|
+
const signingAuthorityNameSchema = z
|
|
16
|
+
.string()
|
|
17
|
+
.max(15, 'must be at most 15 characters')
|
|
18
|
+
.regex(SIGNING_AUTHORITY_NAME_PATTERN, 'must be lowercase letters, numbers, and hyphens');
|
|
19
|
+
|
|
20
|
+
const hostedSigningAuthoritySchema = z
|
|
21
|
+
.object({
|
|
22
|
+
type: z.literal('learncard-hosted'),
|
|
23
|
+
name: signingAuthorityNameSchema,
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
|
|
27
|
+
const selfHostedSigningAuthoritySchema = z
|
|
28
|
+
.object({
|
|
29
|
+
type: z.literal('self-hosted'),
|
|
30
|
+
name: signingAuthorityNameSchema,
|
|
31
|
+
endpoint: z
|
|
32
|
+
.string()
|
|
33
|
+
.url('must be a valid URL')
|
|
34
|
+
.startsWith('https://', 'must be an https:// URL'),
|
|
35
|
+
did: z.string().regex(/^did:/, 'must be a DID (did:web:..., did:key:...)'),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
|
|
39
|
+
const signingAuthoritySchema = z.discriminatedUnion('type', [
|
|
40
|
+
hostedSigningAuthoritySchema,
|
|
41
|
+
selfHostedSigningAuthoritySchema,
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const hexColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'must be a hex color like #18224E');
|
|
45
|
+
|
|
46
|
+
const imageUrlSchema = z.string().superRefine((value, ctx) => {
|
|
47
|
+
if (/^https:\/\//.test(value)) return;
|
|
48
|
+
ctx.addIssue({
|
|
49
|
+
code: 'custom',
|
|
50
|
+
message: /^(\.{1,2}\/|\/|[a-zA-Z]:\\)/.test(value)
|
|
51
|
+
? 'local files are not uploaded yet — host the image and use its https:// URL'
|
|
52
|
+
: 'must be an https:// URL',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const displaySchema = z
|
|
57
|
+
.object({
|
|
58
|
+
backgroundColor: hexColorSchema.optional(),
|
|
59
|
+
backgroundImage: imageUrlSchema.optional(),
|
|
60
|
+
fadeBackgroundImage: z.boolean().optional(),
|
|
61
|
+
repeatBackgroundImage: z.boolean().optional(),
|
|
62
|
+
fontColor: hexColorSchema.optional(),
|
|
63
|
+
accentColor: hexColorSchema.optional(),
|
|
64
|
+
accentFontColor: hexColorSchema.optional(),
|
|
65
|
+
idBackgroundImage: imageUrlSchema.optional(),
|
|
66
|
+
fadeIdBackgroundImage: z.boolean().optional(),
|
|
67
|
+
idBackgroundColor: hexColorSchema.optional(),
|
|
68
|
+
repeatIdBackgroundImage: z.boolean().optional(),
|
|
69
|
+
})
|
|
70
|
+
.strict();
|
|
71
|
+
|
|
72
|
+
export const brandingSchema = z
|
|
73
|
+
.object({
|
|
74
|
+
image: imageUrlSchema.optional(),
|
|
75
|
+
heroImage: imageUrlSchema.optional(),
|
|
76
|
+
shortBio: z.string().max(280, 'must be at most 280 characters').optional(),
|
|
77
|
+
bio: z.string().optional(),
|
|
78
|
+
websiteLink: z.string().url('must be a valid URL').optional(),
|
|
79
|
+
type: z.enum(['organization', 'service', 'person']).optional(),
|
|
80
|
+
display: displaySchema.optional(),
|
|
81
|
+
})
|
|
82
|
+
.strict();
|
|
83
|
+
|
|
84
|
+
export type OrgBranding = z.infer<typeof brandingSchema>;
|
|
85
|
+
|
|
86
|
+
const issuerSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
profileId: profileIdSchema,
|
|
89
|
+
displayName: displayNameSchema,
|
|
90
|
+
branding: brandingSchema.optional(),
|
|
91
|
+
signingAuthority: signingAuthoritySchema,
|
|
92
|
+
})
|
|
93
|
+
.strict();
|
|
94
|
+
|
|
95
|
+
const managedProfileSchema = z
|
|
96
|
+
.object({
|
|
97
|
+
profileId: profileIdSchema,
|
|
98
|
+
displayName: displayNameSchema,
|
|
99
|
+
branding: brandingSchema.optional(),
|
|
100
|
+
})
|
|
101
|
+
.strict();
|
|
102
|
+
|
|
103
|
+
const profileManagerSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
displayName: displayNameSchema,
|
|
106
|
+
managed: z.array(managedProfileSchema).default([]),
|
|
107
|
+
})
|
|
108
|
+
.strict();
|
|
109
|
+
|
|
110
|
+
const scopesSchema = z
|
|
111
|
+
.array(z.string().min(1, 'must not be empty'))
|
|
112
|
+
.min(1, 'must list at least one scope')
|
|
113
|
+
.superRefine((scopes, ctx) => {
|
|
114
|
+
try {
|
|
115
|
+
validateScope(scopes.join(' '));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
ctx.addIssue({
|
|
118
|
+
code: 'custom',
|
|
119
|
+
message: error instanceof Error ? error.message : 'Invalid scope',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const isoDateSchema = z.string().refine(value => !Number.isNaN(Date.parse(value)), {
|
|
125
|
+
message: 'must be a valid ISO date',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** Normalize a service-account name to its shell-safe secrets-file key. */
|
|
129
|
+
export const toEnvKey = (name: string): string => name.replace(/-/g, '_').toUpperCase();
|
|
130
|
+
|
|
131
|
+
// Becomes the key in `--secrets-out`, so keep it shell-safe.
|
|
132
|
+
const SERVICE_ACCOUNT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
133
|
+
|
|
134
|
+
const serviceAccountNameSchema = z
|
|
135
|
+
.string()
|
|
136
|
+
.regex(
|
|
137
|
+
SERVICE_ACCOUNT_NAME_PATTERN,
|
|
138
|
+
'must start with a letter or underscore and contain only letters, numbers, hyphens, and underscores'
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// '*' = any profile under profileManager.managed; a list is refined below against it.
|
|
142
|
+
const actAsSchema = z.union([
|
|
143
|
+
z.literal('*'),
|
|
144
|
+
z.array(profileIdSchema).min(1, 'must list at least one profileId'),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const serviceAccountSchema = z
|
|
148
|
+
.object({
|
|
149
|
+
name: serviceAccountNameSchema,
|
|
150
|
+
scopes: scopesSchema,
|
|
151
|
+
expiresAt: isoDateSchema.optional(),
|
|
152
|
+
actAs: actAsSchema.optional(),
|
|
153
|
+
})
|
|
154
|
+
.strict();
|
|
155
|
+
|
|
156
|
+
const webhookSchema = z
|
|
157
|
+
.object({
|
|
158
|
+
url: z
|
|
159
|
+
.string()
|
|
160
|
+
.url('must be a valid URL')
|
|
161
|
+
.startsWith('https://', 'must be an https:// URL'),
|
|
162
|
+
})
|
|
163
|
+
.strict();
|
|
164
|
+
|
|
165
|
+
export const OrgSpecValidator = z
|
|
166
|
+
.object({
|
|
167
|
+
issuer: issuerSchema,
|
|
168
|
+
profileManager: profileManagerSchema.optional(),
|
|
169
|
+
serviceAccounts: z
|
|
170
|
+
.array(serviceAccountSchema)
|
|
171
|
+
.superRefine((accounts, ctx) => {
|
|
172
|
+
const keys = new Set<string>();
|
|
173
|
+
accounts.forEach((account, index) => {
|
|
174
|
+
const key = toEnvKey(account.name);
|
|
175
|
+
if (keys.has(key))
|
|
176
|
+
ctx.addIssue({
|
|
177
|
+
code: 'custom',
|
|
178
|
+
path: [index, 'name'],
|
|
179
|
+
message: `Duplicate service-account secrets key "${key}" after normalizing names`,
|
|
180
|
+
});
|
|
181
|
+
keys.add(key);
|
|
182
|
+
});
|
|
183
|
+
})
|
|
184
|
+
.optional(),
|
|
185
|
+
webhooks: z.array(webhookSchema).optional(),
|
|
186
|
+
})
|
|
187
|
+
.strict()
|
|
188
|
+
.superRefine((spec, ctx) => {
|
|
189
|
+
const managedIds = new Set(spec.profileManager?.managed.map(entry => entry.profileId));
|
|
190
|
+
spec.serviceAccounts?.forEach((account, index) => {
|
|
191
|
+
if (!account.actAs) return;
|
|
192
|
+
const path = ['serviceAccounts', index, 'actAs'];
|
|
193
|
+
if (account.actAs === '*') {
|
|
194
|
+
if (!spec.profileManager)
|
|
195
|
+
ctx.addIssue({
|
|
196
|
+
code: 'custom',
|
|
197
|
+
path,
|
|
198
|
+
message: '"*" requires a profileManager in this spec',
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const profileId of account.actAs) {
|
|
203
|
+
if (!managedIds.has(profileId))
|
|
204
|
+
ctx.addIssue({
|
|
205
|
+
code: 'custom',
|
|
206
|
+
path,
|
|
207
|
+
message: `"${profileId}" is not a managed profile in this spec`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
export type OrgSpec = z.infer<typeof OrgSpecValidator>;
|
|
214
|
+
export type OrgSigningAuthoritySpec = z.infer<typeof signingAuthoritySchema>;
|
|
215
|
+
export type OrgManagedProfileSpec = z.infer<typeof managedProfileSchema>;
|
|
216
|
+
export type OrgServiceAccountSpec = z.infer<typeof serviceAccountSchema>;
|