ankka-console 0.10.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.
Files changed (99) hide show
  1. package/README.md +32 -0
  2. package/dist/auth/oidc.d.ts +64 -0
  3. package/dist/auth/oidc.js +199 -0
  4. package/dist/client/control-plane.d.ts +67 -0
  5. package/dist/client/control-plane.js +208 -0
  6. package/dist/client/errors.d.ts +26 -0
  7. package/dist/client/errors.js +59 -0
  8. package/dist/client/index.d.ts +4 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/schemas.d.ts +365 -0
  11. package/dist/client/schemas.js +221 -0
  12. package/dist/context.d.ts +83 -0
  13. package/dist/context.js +156 -0
  14. package/dist/extensions/render.d.ts +20 -0
  15. package/dist/extensions/render.js +49 -0
  16. package/dist/extensions/types.d.ts +44 -0
  17. package/dist/extensions/types.js +30 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +5 -0
  20. package/dist/middleware.d.ts +14 -0
  21. package/dist/middleware.js +48 -0
  22. package/dist/options.d.ts +45 -0
  23. package/dist/options.js +39 -0
  24. package/dist/routes/auth.callback.d.ts +11 -0
  25. package/dist/routes/auth.callback.js +43 -0
  26. package/dist/routes/auth.sign-in.d.ts +11 -0
  27. package/dist/routes/auth.sign-in.js +29 -0
  28. package/dist/routes/auth.sign-out.d.ts +19 -0
  29. package/dist/routes/auth.sign-out.js +37 -0
  30. package/dist/routes/front.d.ts +41 -0
  31. package/dist/routes/front.js +25 -0
  32. package/dist/routes/logs.d.ts +67 -0
  33. package/dist/routes/logs.js +72 -0
  34. package/dist/routes/members.d.ts +45 -0
  35. package/dist/routes/members.js +59 -0
  36. package/dist/routes/organization-new.d.ts +14 -0
  37. package/dist/routes/organization-new.js +30 -0
  38. package/dist/routes/organization.d.ts +43 -0
  39. package/dist/routes/organization.js +82 -0
  40. package/dist/routes/project-new.d.ts +27 -0
  41. package/dist/routes/project-new.js +27 -0
  42. package/dist/routes/project.d.ts +59 -0
  43. package/dist/routes/project.js +61 -0
  44. package/dist/routes/service-apply.d.ts +44 -0
  45. package/dist/routes/service-apply.js +65 -0
  46. package/dist/routes/service.d.ts +71 -0
  47. package/dist/routes/service.js +73 -0
  48. package/dist/routes/stream.project.d.ts +3 -0
  49. package/dist/routes/stream.project.js +25 -0
  50. package/dist/routes/stream.service.d.ts +9 -0
  51. package/dist/routes/stream.service.js +68 -0
  52. package/dist/routes/tokens.d.ts +47 -0
  53. package/dist/routes/tokens.js +50 -0
  54. package/dist/routes.d.ts +28 -0
  55. package/dist/routes.js +48 -0
  56. package/dist/runtime.d.ts +28 -0
  57. package/dist/runtime.js +77 -0
  58. package/dist/server/create-console-server.d.ts +33 -0
  59. package/dist/server/create-console-server.js +203 -0
  60. package/dist/server/index.d.ts +15 -0
  61. package/dist/server/index.js +13 -0
  62. package/dist/server/transport.d.ts +25 -0
  63. package/dist/server/transport.js +97 -0
  64. package/dist/session/cookie-store.d.ts +51 -0
  65. package/dist/session/cookie-store.js +66 -0
  66. package/dist/session/cookies.d.ts +15 -0
  67. package/dist/session/cookies.js +33 -0
  68. package/dist/session/seal.d.ts +12 -0
  69. package/dist/session/seal.js +38 -0
  70. package/dist/session/token-cache.d.ts +21 -0
  71. package/dist/session/token-cache.js +47 -0
  72. package/dist/session/token-source.d.ts +41 -0
  73. package/dist/session/token-source.js +63 -0
  74. package/dist/stream/log-follow.d.ts +18 -0
  75. package/dist/stream/log-follow.js +40 -0
  76. package/dist/stream/registry.d.ts +6 -0
  77. package/dist/stream/registry.js +34 -0
  78. package/dist/stream/sse.d.ts +14 -0
  79. package/dist/stream/sse.js +87 -0
  80. package/dist/styles.css +481 -0
  81. package/dist/testing/fake-control-plane.d.ts +161 -0
  82. package/dist/testing/fake-control-plane.js +715 -0
  83. package/dist/testing/fake-issuer.d.ts +66 -0
  84. package/dist/testing/fake-issuer.js +303 -0
  85. package/dist/testing/index.d.ts +5 -0
  86. package/dist/testing/index.js +3 -0
  87. package/dist/testing/scenarios.d.ts +6 -0
  88. package/dist/testing/scenarios.js +44 -0
  89. package/dist/ui/console.d.ts +60 -0
  90. package/dist/ui/console.js +73 -0
  91. package/dist/ui/errors.d.ts +6 -0
  92. package/dist/ui/errors.js +30 -0
  93. package/dist/ui/refused.d.ts +6 -0
  94. package/dist/ui/refused.js +12 -0
  95. package/dist/ui/status.d.ts +5 -0
  96. package/dist/ui/status.js +23 -0
  97. package/dist/ui/use-stream.d.ts +59 -0
  98. package/dist/ui/use-stream.js +67 -0
  99. package/package.json +79 -0
@@ -0,0 +1,715 @@
1
+ /**
2
+ * A control plane in one process: every route the console uses, over in-memory state, with the
3
+ * control plane's rules as the console can observe them — who sees what, `404` rather than `403`
4
+ * for an outsider, owner-only operations, tombstoned ids, `409` for a non-empty delete, a disabled
5
+ * organization refusing changes, descriptor validation, a deploy token shown once.
6
+ *
7
+ * It answers from state rather than from a script, so it cannot run out quietly. What a test needs
8
+ * to steer is explicit: `script()` makes the next answer on one route a given status, `tick()` moves
9
+ * services through their lifecycle, `appendLog()` writes a line, and `hideNewFromListings()` makes
10
+ * listings lag writes the way the real control plane's projections do. `visited` records every route
11
+ * template exercised, which the parity test reads.
12
+ */
13
+ import { createServer } from "node:http";
14
+ import { randomBytes } from "node:crypto";
15
+ class HttpError extends Error {
16
+ status;
17
+ constructor(status, message) {
18
+ super(message);
19
+ this.status = status;
20
+ }
21
+ }
22
+ const now = () => new Date().toISOString();
23
+ const NameRule = /^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$/;
24
+ function decodeJwt(token) {
25
+ const parts = token.split(".");
26
+ if (parts.length !== 3)
27
+ return null;
28
+ try {
29
+ const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
30
+ if (typeof claims.exp === "number" && claims.exp * 1000 < Date.now())
31
+ return null;
32
+ return claims;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ export async function fakeControlPlane(options = {}) {
39
+ const verify = options.verify ?? decodeJwt;
40
+ const organizations = new Map();
41
+ const projects = new Map();
42
+ const services = new Map();
43
+ const tokens = new Map();
44
+ const tombstones = new Set();
45
+ const visited = new Set();
46
+ const counts = new Map();
47
+ const scripted = new Map();
48
+ let hideNew = false;
49
+ let base = "";
50
+ const serviceKey = (p, n) => `${p}/${n}`;
51
+ // ── Views of state ─────────────────────────────────────────────────────────
52
+ const orgSummary = (org, caller) => {
53
+ const member = org.members.get(caller.subject);
54
+ const projectCount = [...projects.values()].filter((p) => p.organizationId === org.id).length;
55
+ const svc = [...services.values()].filter((s) => projects.get(s.projectId)?.organizationId === org.id);
56
+ return {
57
+ id: org.id,
58
+ name: org.name,
59
+ projects: projectCount,
60
+ disabled: org.disabled,
61
+ role: member?.role ?? null,
62
+ quota: org.quota ?? null,
63
+ usage: {
64
+ projects: projectCount,
65
+ services: svc.length,
66
+ instances: svc.reduce((n, s) => n + s.desiredInstances, 0),
67
+ },
68
+ };
69
+ };
70
+ const registrySummary = (r) => r ? { server: r.server, username: r.username, setAt: r.setAt, setBy: r.setBy ?? null } : null;
71
+ const serviceStatus = (s) => {
72
+ const org = organizations.get(projects.get(s.projectId)?.organizationId ?? "");
73
+ const suspended = org?.disabled ?? false;
74
+ return {
75
+ name: s.name,
76
+ projectId: s.projectId,
77
+ lifecycle: suspended ? "Suspended" : s.lifecycle,
78
+ generation: s.generation,
79
+ image: s.image,
80
+ readyInstances: suspended ? 0 : s.readyInstances,
81
+ desiredInstances: s.desiredInstances,
82
+ detail: s.detail ?? null,
83
+ confirmed: true,
84
+ database: "provisioned",
85
+ hostname: s.exposed ? `https://${s.name}-${s.projectId}.${options.baseDomain ?? "example.test"}` : null,
86
+ exposed: s.exposed,
87
+ suspended,
88
+ paused: s.paused,
89
+ hosting: "embedded",
90
+ protocol: null,
91
+ };
92
+ };
93
+ // ── Authorization, the control plane's rules ──────────────────────────────
94
+ const isAdmin = (c) => c.admin;
95
+ function claimInvitations(c) {
96
+ if (!c.email || !c.emailVerified)
97
+ return;
98
+ for (const org of organizations.values()) {
99
+ const inv = org.invitations.get(c.email);
100
+ if (inv && !org.members.has(c.subject)) {
101
+ org.members.set(c.subject, { role: inv.role, email: c.email, display: c.name, since: now(), addedBy: inv.invitedBy });
102
+ org.invitations.delete(c.email);
103
+ }
104
+ }
105
+ }
106
+ function requireOrg(c, id) {
107
+ const org = organizations.get(id);
108
+ if (!org)
109
+ throw new HttpError(404, `organization '${id}' not found`);
110
+ const member = org.members.get(c.subject);
111
+ if (!member && !isAdmin(c))
112
+ throw new HttpError(404, `organization '${id}' not found`);
113
+ return { org, role: member?.role };
114
+ }
115
+ function requireWrite(org) {
116
+ if (org.disabled)
117
+ throw new HttpError(409, `organization '${org.id}' is disabled`);
118
+ }
119
+ function requireOwner(c, id) {
120
+ const { org, role } = requireOrg(c, id);
121
+ if (role !== "owner" && !isAdmin(c))
122
+ throw new HttpError(403, `owner role required in organization '${id}'`);
123
+ return org;
124
+ }
125
+ function requireProject(c, id) {
126
+ const project = projects.get(id);
127
+ if (!project)
128
+ throw new HttpError(404, `project '${id}' not found`);
129
+ const org = organizations.get(project.organizationId);
130
+ if (!org || (!org.members.has(c.subject) && !isAdmin(c)))
131
+ throw new HttpError(404, `project '${id}' not found`);
132
+ return { project, org };
133
+ }
134
+ function requireService(c, p, n) {
135
+ const { org } = requireProject(c, p);
136
+ const service = services.get(serviceKey(p, n));
137
+ if (!service)
138
+ throw new HttpError(404, `service '${n}' not found in project '${p}'`);
139
+ return { service, org };
140
+ }
141
+ const actor = (c) => ({ subject: c.subject, display: c.name ?? c.email, administrative: c.admin });
142
+ const routes = [];
143
+ function route(method, template, handler, anonymous = false) {
144
+ const keys = [];
145
+ const pattern = new RegExp("^" + template.replace(/\{(\w+)\}/g, (_m, k) => (keys.push(k), "([^/]+)")) + "$");
146
+ routes.push({ method, template, pattern, keys, handler, anonymous });
147
+ }
148
+ route("GET", "/auth", () => ({
149
+ issuer: options.issuer ?? "http://issuer.invalid/realms/ankka",
150
+ clientId: options.clientId ?? "ankka-cli",
151
+ audience: options.audience ?? "ankka-controlplane",
152
+ }), true);
153
+ route("GET", "/auth/whoami", (c) => {
154
+ claimInvitations(c);
155
+ return {
156
+ subject: c.subject,
157
+ name: c.name ?? null,
158
+ email: c.email ?? null,
159
+ emailVerified: c.emailVerified,
160
+ platformAdmin: c.admin,
161
+ organizations: isAdmin(c)
162
+ ? []
163
+ : [...organizations.values()]
164
+ .filter((o) => o.members.has(c.subject))
165
+ .map((o) => ({ id: o.id, name: o.name, role: o.members.get(c.subject).role })),
166
+ };
167
+ });
168
+ route("GET", "/organizations", (c) => {
169
+ claimInvitations(c);
170
+ return [...organizations.values()]
171
+ .filter((o) => !o.hidden && (isAdmin(c) || o.members.has(c.subject)))
172
+ .map((o) => orgSummary(o, c));
173
+ });
174
+ route("GET", "/organizations/{organizationId}", (c, p) => {
175
+ const { org } = requireOrg(c, p.organizationId);
176
+ const s = orgSummary(org, c);
177
+ return isAdmin(c) && !org.members.has(c.subject) ? { ...s, role: null } : s;
178
+ });
179
+ route("POST", "/organizations/{organizationId}", (c, p, body) => {
180
+ const b = body;
181
+ if (b.owner && !isAdmin(c))
182
+ throw new HttpError(403, "platform administrator role required to name an owner");
183
+ if ((options.organizationCreation ?? "open") === "platform-admin" && !isAdmin(c)) {
184
+ const where = options.signupUrl ? `; sign up at ${options.signupUrl}` : "";
185
+ throw new HttpError(403, `organizations in this installation are created by the platform administrator${where}`);
186
+ }
187
+ const id = p.organizationId;
188
+ if (!NameRule.test(id))
189
+ throw new HttpError(400, `organization id '${id}' is invalid: lowercase letters, digits and '-', starting with a letter`);
190
+ if (!b.name)
191
+ throw new HttpError(400, "organization name must not be empty");
192
+ if (tombstones.has(`org:${id}`))
193
+ throw new HttpError(409, `organization '${id}' was deleted; its id is not reused`);
194
+ if (organizations.has(id))
195
+ throw new HttpError(409, `organization '${id}' already exists`);
196
+ const owner = b.owner ?? { subject: c.subject, email: c.email, display: c.name };
197
+ const org = {
198
+ id,
199
+ name: b.name,
200
+ disabled: false,
201
+ members: new Map([[owner.subject, { role: "owner", email: owner.email, display: owner.display, since: now(), addedBy: c.name }]]),
202
+ invitations: new Map(),
203
+ hidden: hideNew,
204
+ };
205
+ organizations.set(id, org);
206
+ return "done";
207
+ });
208
+ route("PUT", "/organizations/{organizationId}/name", (c, p, body) => {
209
+ const org = requireOwner(c, p.organizationId);
210
+ requireWrite(org);
211
+ const name = body.name;
212
+ if (!name)
213
+ throw new HttpError(400, "organization name must not be empty");
214
+ org.name = name;
215
+ return "done";
216
+ });
217
+ route("DELETE", "/organizations/{organizationId}", (c, p) => {
218
+ const org = requireOwner(c, p.organizationId);
219
+ const remaining = [...projects.values()].filter((x) => x.organizationId === org.id).length;
220
+ if (remaining > 0)
221
+ throw new HttpError(409, `organization '${org.id}' still has ${remaining} project(s)`);
222
+ organizations.delete(org.id);
223
+ tombstones.add(`org:${org.id}`);
224
+ return "done";
225
+ });
226
+ route("GET", "/organizations/{organizationId}/members", (c, p) => {
227
+ const { org } = requireOrg(c, p.organizationId);
228
+ return {
229
+ members: [...org.members.entries()].map(([subject, m]) => ({ subject, role: m.role, email: m.email ?? null, display: m.display ?? null, since: m.since, addedBy: m.addedBy ?? null })),
230
+ invitations: [...org.invitations.entries()].map(([email, i]) => ({ email, role: i.role, invitedAt: i.invitedAt, invitedBy: i.invitedBy ?? null })),
231
+ };
232
+ });
233
+ route("POST", "/organizations/{organizationId}/members", (c, p, body) => {
234
+ const org = requireOwner(c, p.organizationId);
235
+ requireWrite(org);
236
+ const b = body;
237
+ if (!b.email || !b.email.includes("@"))
238
+ throw new HttpError(400, `'${b.email ?? ""}' is not an email address`);
239
+ org.invitations.set(b.email, { role: b.role ?? "member", invitedAt: now(), invitedBy: c.name ?? c.subject });
240
+ return "done";
241
+ });
242
+ const owners = (org) => [...org.members.values()].filter((m) => m.role === "owner").length;
243
+ route("DELETE", "/organizations/{organizationId}/members/{subject}", (c, p) => {
244
+ const org = requireOwner(c, p.organizationId);
245
+ requireWrite(org);
246
+ const m = org.members.get(p.subject);
247
+ if (!m)
248
+ throw new HttpError(404, `'${p.subject}' is not a member of organization '${org.id}'`);
249
+ if (m.role === "owner" && owners(org) === 1)
250
+ throw new HttpError(409, `'${p.subject}' is the last owner of organization '${org.id}'`);
251
+ org.members.delete(p.subject);
252
+ return "done";
253
+ });
254
+ route("PUT", "/organizations/{organizationId}/members/{subject}/role", (c, p, body) => {
255
+ const org = requireOwner(c, p.organizationId);
256
+ requireWrite(org);
257
+ const m = org.members.get(p.subject);
258
+ if (!m)
259
+ throw new HttpError(404, `'${p.subject}' is not a member of organization '${org.id}'`);
260
+ const role = body.role;
261
+ if (m.role === "owner" && role !== "owner" && owners(org) === 1)
262
+ throw new HttpError(409, `'${p.subject}' is the last owner of organization '${org.id}'`);
263
+ m.role = role;
264
+ return "done";
265
+ });
266
+ route("DELETE", "/organizations/{organizationId}/invitations/{email}", (c, p) => {
267
+ const org = requireOwner(c, p.organizationId);
268
+ requireWrite(org);
269
+ if (!org.invitations.delete(p.email))
270
+ throw new HttpError(404, `no invitation for '${p.email}'`);
271
+ return "done";
272
+ });
273
+ route("POST", "/organizations/{organizationId}/members/{subject}/repair", (c, p, body) => {
274
+ if (!isAdmin(c))
275
+ throw new HttpError(403, "platform administrator role required");
276
+ const org = organizations.get(p.organizationId);
277
+ if (!org)
278
+ throw new HttpError(404, `organization '${p.organizationId}' not found`);
279
+ org.members.set(p.subject, { role: body.role ?? "owner", since: now(), addedBy: c.name });
280
+ return "done";
281
+ });
282
+ route("GET", "/organizations/{organizationId}/tokens", (c, p) => {
283
+ requireOwner(c, p.organizationId);
284
+ return [...tokens.values()]
285
+ .filter((t) => t.organizationId === p.organizationId && !t.revoked)
286
+ .map((t) => ({ id: t.id, label: t.label, subject: t.subject, createdBy: t.createdBy ?? null, createdAt: t.createdAt, expiresAt: t.expiresAt ?? null, lastUsed: null }));
287
+ });
288
+ route("POST", "/organizations/{organizationId}/tokens", (c, p, body) => {
289
+ const org = requireOwner(c, p.organizationId);
290
+ if (c.tokenOrg)
291
+ throw new HttpError(403, "a deploy token cannot manage deploy tokens");
292
+ requireWrite(org);
293
+ const b = body;
294
+ if (!b.label)
295
+ throw new HttpError(400, "token label must not be empty");
296
+ const id = randomBytes(6).toString("hex");
297
+ const secret = `ankka_${id}_${randomBytes(18).toString("base64url")}`;
298
+ const t = {
299
+ id,
300
+ organizationId: org.id,
301
+ label: b.label,
302
+ subject: `token:${id}`,
303
+ secret,
304
+ createdBy: c.name ?? c.subject,
305
+ createdAt: now(),
306
+ // Absent is the platform's 90-day default; 0 is never.
307
+ expiresAt: b.expiresIn === 0 ? undefined : new Date(Date.now() + (b.expiresIn ?? 90 * 86_400) * 1000).toISOString(),
308
+ revoked: false,
309
+ };
310
+ tokens.set(id, t);
311
+ org.members.set(t.subject, { role: "member", display: b.label, since: now(), addedBy: c.name });
312
+ return { id, label: t.label, secret, subject: t.subject, expiresAt: t.expiresAt ?? null };
313
+ });
314
+ route("DELETE", "/organizations/{organizationId}/tokens/{tokenId}", (c, p) => {
315
+ const org = requireOwner(c, p.organizationId);
316
+ const t = tokens.get(p.tokenId);
317
+ if (!t || t.organizationId !== org.id || t.revoked)
318
+ throw new HttpError(404, `deploy token '${p.tokenId}' not found`);
319
+ t.revoked = true;
320
+ org.members.delete(t.subject);
321
+ return "done";
322
+ });
323
+ route("POST", "/organizations/{organizationId}/disable", (c, p) => {
324
+ if (!isAdmin(c))
325
+ throw new HttpError(403, "platform administrator role required");
326
+ const org = organizations.get(p.organizationId);
327
+ if (!org)
328
+ throw new HttpError(404, `organization '${p.organizationId}' not found`);
329
+ org.disabled = true;
330
+ return "done";
331
+ });
332
+ route("POST", "/organizations/{organizationId}/enable", (c, p) => {
333
+ if (!isAdmin(c))
334
+ throw new HttpError(403, "platform administrator role required");
335
+ const org = organizations.get(p.organizationId);
336
+ if (!org)
337
+ throw new HttpError(404, `organization '${p.organizationId}' not found`);
338
+ org.disabled = false;
339
+ return "done";
340
+ });
341
+ route("PUT", "/organizations/{organizationId}/quota", (c, p, body) => {
342
+ if (!isAdmin(c))
343
+ throw new HttpError(403, "platform administrator role required");
344
+ const org = organizations.get(p.organizationId);
345
+ if (!org)
346
+ throw new HttpError(404, `organization '${p.organizationId}' not found`);
347
+ const q = body;
348
+ org.quota = {
349
+ projects: q.projects ?? undefined,
350
+ services: q.services ?? undefined,
351
+ instances: q.instances ?? undefined,
352
+ };
353
+ return "done";
354
+ });
355
+ route("DELETE", "/organizations/{organizationId}/quota", (c, p) => {
356
+ if (!isAdmin(c))
357
+ throw new HttpError(403, "platform administrator role required");
358
+ const org = organizations.get(p.organizationId);
359
+ if (!org)
360
+ throw new HttpError(404, `organization '${p.organizationId}' not found`);
361
+ org.quota = undefined;
362
+ return "done";
363
+ });
364
+ route("GET", "/projects", (c) => [...projects.values()]
365
+ .filter((p) => !p.hidden)
366
+ .filter((p) => {
367
+ const org = organizations.get(p.organizationId);
368
+ return org && (isAdmin(c) || org.members.has(c.subject));
369
+ })
370
+ .map((p) => ({
371
+ id: p.id,
372
+ name: p.name,
373
+ organizationId: p.organizationId,
374
+ services: [...services.values()].filter((s) => s.projectId === p.id).length,
375
+ registry: registrySummary(p.registry),
376
+ })));
377
+ route("GET", "/projects/{projectId}", (c, p) => {
378
+ const { project } = requireProject(c, p.projectId);
379
+ return { id: project.id, name: project.name, organizationId: project.organizationId, registry: registrySummary(project.registry) };
380
+ });
381
+ route("POST", "/projects/{projectId}", (c, p, body) => {
382
+ const b = body;
383
+ const { org } = requireOrg(c, b.organizationId ?? "");
384
+ requireWrite(org);
385
+ const id = p.projectId;
386
+ if (!NameRule.test(id))
387
+ throw new HttpError(400, `project id '${id}' is invalid: lowercase letters, digits and '-', starting with a letter`);
388
+ if (id === "platform")
389
+ throw new HttpError(400, `project id '${id}' is reserved for the platform's own workloads`);
390
+ if (!b.name)
391
+ throw new HttpError(400, "project name must not be empty");
392
+ if (tombstones.has(`project:${id}`))
393
+ throw new HttpError(409, `project '${id}' was deleted; its id is not reused`);
394
+ if (projects.has(id))
395
+ throw new HttpError(409, `project '${id}' already exists`);
396
+ const count = [...projects.values()].filter((x) => x.organizationId === org.id).length;
397
+ if (org.quota?.projects !== undefined && count >= org.quota.projects)
398
+ throw new HttpError(409, `organization '${org.id}' is at its quota of ${org.quota.projects} project(s)`);
399
+ projects.set(id, { id, name: b.name, organizationId: org.id, hidden: hideNew });
400
+ return "done";
401
+ });
402
+ route("PUT", "/projects/{projectId}/name", (c, p, body) => {
403
+ const { project, org } = requireProject(c, p.projectId);
404
+ requireWrite(org);
405
+ const name = body.name;
406
+ if (!name)
407
+ throw new HttpError(400, "project name must not be empty");
408
+ project.name = name;
409
+ return "done";
410
+ });
411
+ route("DELETE", "/projects/{projectId}", (c, p) => {
412
+ const { project } = requireProject(c, p.projectId);
413
+ const remaining = [...services.values()].filter((s) => s.projectId === project.id).length;
414
+ if (remaining > 0)
415
+ throw new HttpError(409, `project '${project.id}' still has ${remaining} service(s)`);
416
+ projects.delete(project.id);
417
+ tombstones.add(`project:${project.id}`);
418
+ return "done";
419
+ });
420
+ route("PUT", "/projects/{projectId}/registry", (c, p, body) => {
421
+ const { project, org } = requireProject(c, p.projectId);
422
+ requireWrite(org);
423
+ const b = body;
424
+ if (!b.server || !b.username || !b.password)
425
+ throw new HttpError(400, "server, username and password are all required");
426
+ project.registry = { server: b.server, username: b.username, password: b.password, setAt: now(), setBy: c.name ?? c.subject };
427
+ return "done";
428
+ });
429
+ route("DELETE", "/projects/{projectId}/registry", (c, p) => {
430
+ const { project, org } = requireProject(c, p.projectId);
431
+ requireWrite(org);
432
+ project.registry = undefined;
433
+ return "done";
434
+ });
435
+ route("GET", "/services/{projectId}", (c, p) => {
436
+ requireProject(c, p.projectId);
437
+ return [...services.values()].filter((s) => s.projectId === p.projectId).map(serviceStatus);
438
+ });
439
+ route("GET", "/services/{projectId}/{name}", (c, p) => serviceStatus(requireService(c, p.projectId, p.name).service));
440
+ route("PUT", "/services/{projectId}/{name}", (c, p, body) => {
441
+ const { org } = requireProject(c, p.projectId);
442
+ requireWrite(org);
443
+ const d = body;
444
+ if (d?.name !== p.name)
445
+ throw new HttpError(400, `descriptor names service '${d?.name ?? ""}' but was applied to '${p.name}'`);
446
+ const problems = [];
447
+ if (!NameRule.test(p.name))
448
+ problems.push(`service name '${p.name}' is invalid: lowercase letters, digits and '-', starting with a letter`);
449
+ if (!d.service?.image)
450
+ problems.push("image must not be empty");
451
+ if (problems.length > 0)
452
+ throw new HttpError(400, `invalid descriptor: ${problems.join("; ")}`);
453
+ const key = serviceKey(p.projectId, p.name);
454
+ const instances = d.service?.resources?.autoscaling?.minInstances ?? 1;
455
+ const existing = services.get(key);
456
+ const s = existing ?? {
457
+ name: p.name,
458
+ projectId: p.projectId,
459
+ lifecycle: "NotDeployed",
460
+ generation: 0,
461
+ image: "",
462
+ readyInstances: 0,
463
+ desiredInstances: instances,
464
+ exposed: false,
465
+ paused: false,
466
+ history: [],
467
+ logs: new Map(),
468
+ previousLogs: new Map(),
469
+ };
470
+ s.image = d.service.image;
471
+ s.generation += 1;
472
+ s.desiredInstances = instances;
473
+ s.lifecycle = s.paused ? "Paused" : "UpdateInProgress";
474
+ s.history.push({ kind: "applied", generation: s.generation, actor: actor(c), at: now() });
475
+ services.set(key, s);
476
+ return serviceStatus(s);
477
+ });
478
+ const operation = (op) => (c, p) => {
479
+ const { service: s, org } = requireService(c, p.projectId, p.name);
480
+ requireWrite(org);
481
+ if (op === "pause") {
482
+ s.paused = true;
483
+ s.lifecycle = "Paused";
484
+ s.readyInstances = 0;
485
+ }
486
+ else if (op === "resume") {
487
+ s.paused = false;
488
+ s.lifecycle = "UpdateInProgress";
489
+ }
490
+ else if (op === "restart") {
491
+ if (s.paused)
492
+ throw new HttpError(409, `service '${s.name}' is paused`);
493
+ s.generation += 1;
494
+ s.lifecycle = "UpdateInProgress";
495
+ for (const [k, v] of s.logs)
496
+ s.previousLogs.set(k, v);
497
+ s.logs = new Map();
498
+ }
499
+ else if (op === "expose") {
500
+ s.exposed = true;
501
+ }
502
+ else {
503
+ s.exposed = false;
504
+ }
505
+ s.history.push({ kind: op === "pause" ? "paused" : op === "resume" ? "resumed" : op === "restart" ? "restarted" : op === "expose" ? "exposed" : "unexposed", generation: s.generation, actor: actor(c), at: now() });
506
+ return "done";
507
+ };
508
+ for (const op of ["pause", "resume", "restart", "expose", "unexpose"]) {
509
+ route("POST", `/services/{projectId}/{name}/${op}`, operation(op));
510
+ }
511
+ route("GET", "/services/{projectId}/{name}/logs", (c, p, _body, url) => {
512
+ const { service: s } = requireService(c, p.projectId, p.name);
513
+ if (s.paused || s.readyInstances === 0 && s.logs.size === 0)
514
+ throw new HttpError(404, `service '${s.name}' has no running instance`);
515
+ const previous = url.searchParams.get("previous") === "true";
516
+ const tail = url.searchParams.get("tail");
517
+ const only = url.searchParams.get("instance");
518
+ const source = previous ? s.previousLogs : s.logs;
519
+ const names = s.readyInstances > 0 ? Array.from({ length: s.readyInstances }, (_x, i) => `${s.name}-${i}`) : [...source.keys()];
520
+ return {
521
+ instances: names
522
+ .filter((n) => !only || n === only)
523
+ .map((n) => {
524
+ const lines = source.get(n) ?? [];
525
+ const shown = tail ? lines.slice(-Number(tail)) : lines;
526
+ return { instance: n, output: shown.map((l) => l + "\n").join(""), error: null };
527
+ }),
528
+ };
529
+ });
530
+ route("GET", "/services/{projectId}/{name}/history", (c, p) => requireService(c, p.projectId, p.name).service.history);
531
+ route("DELETE", "/services/{projectId}/{name}", (c, p) => {
532
+ const { service: s, org } = requireService(c, p.projectId, p.name);
533
+ requireWrite(org);
534
+ services.delete(serviceKey(s.projectId, s.name));
535
+ return "done";
536
+ });
537
+ // ── The server ─────────────────────────────────────────────────────────────
538
+ async function callerOf(req) {
539
+ const header = req.headers.authorization ?? "";
540
+ if (!header.startsWith("Bearer "))
541
+ return null;
542
+ const token = header.slice(7);
543
+ if (token.startsWith("ankka_")) {
544
+ const id = token.split("_")[1];
545
+ const t = tokens.get(id);
546
+ if (!t || t.revoked || t.secret !== token)
547
+ return null;
548
+ return { subject: t.subject, name: t.label, emailVerified: false, admin: false, tokenOrg: t.organizationId };
549
+ }
550
+ const claims = await verify(token);
551
+ if (!claims)
552
+ return null;
553
+ return {
554
+ subject: claims.sub,
555
+ name: claims.name,
556
+ email: claims.email,
557
+ emailVerified: claims.email_verified ?? false,
558
+ admin: claims.realm_access?.roles?.includes("platform-admin") ?? false,
559
+ };
560
+ }
561
+ function send(res, status, body) {
562
+ // The control plane answers `Done` as 204 with no body.
563
+ if (body === "done" && status === 200) {
564
+ res.writeHead(204).end();
565
+ }
566
+ else if (typeof body === "string") {
567
+ res.writeHead(status, { "content-type": "text/plain" }).end(body);
568
+ }
569
+ else {
570
+ res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
571
+ }
572
+ }
573
+ const server = createServer(async (req, res) => {
574
+ const url = new URL(req.url ?? "/", "http://fake");
575
+ const path = url.pathname;
576
+ const match = routes
577
+ .filter((r) => r.method === req.method)
578
+ .map((r) => ({ r, m: r.pattern.exec(path) }))
579
+ .filter((x) => x.m !== null)
580
+ // Literal segments outrank parameters, as in the real router.
581
+ .sort((a, b) => b.r.template.replace(/\{\w+\}/g, "").length - a.r.template.replace(/\{\w+\}/g, "").length)[0];
582
+ if (!match)
583
+ return send(res, 404, { status: 404, error: `no route for ${req.method} ${path}` });
584
+ const id = `${match.r.method} ${match.r.template}`;
585
+ visited.add(id);
586
+ counts.set(id, (counts.get(id) ?? 0) + 1);
587
+ const queued = scripted.get(id);
588
+ if (queued && queued.length > 0) {
589
+ const next = queued.shift();
590
+ return send(res, next.status, { status: next.status, error: next.error });
591
+ }
592
+ const chunks = [];
593
+ for await (const chunk of req)
594
+ chunks.push(chunk);
595
+ const raw = Buffer.concat(chunks).toString("utf8");
596
+ let body = undefined;
597
+ if (raw.length > 0) {
598
+ try {
599
+ body = JSON.parse(raw);
600
+ }
601
+ catch {
602
+ return send(res, 400, { status: 400, error: "request body is not valid JSON" });
603
+ }
604
+ }
605
+ let caller = null;
606
+ if (!match.r.anonymous) {
607
+ caller = await callerOf(req);
608
+ if (!caller) {
609
+ res.setHeader("www-authenticate", 'Bearer realm="ankka"');
610
+ return send(res, 401, { status: 401, error: "missing, invalid or expired token" });
611
+ }
612
+ }
613
+ const params = {};
614
+ match.r.keys.forEach((k, i) => (params[k] = decodeURIComponent(match.m[i + 1])));
615
+ try {
616
+ const result = match.r.handler(caller, params, body, url);
617
+ send(res, 200, result);
618
+ }
619
+ catch (e) {
620
+ if (e instanceof HttpError)
621
+ return send(res, e.status, { status: e.status, error: e.message });
622
+ send(res, 500, { status: 500, error: String(e) });
623
+ }
624
+ });
625
+ await new Promise((resolve) => server.listen(options.port ?? 0, "127.0.0.1", resolve));
626
+ base = `http://127.0.0.1:${server.address().port}`;
627
+ const fake = {
628
+ url: base,
629
+ server,
630
+ visited,
631
+ counts,
632
+ script(routeId, status, error = "scripted failure") {
633
+ const list = scripted.get(routeId) ?? [];
634
+ list.push({ status, error });
635
+ scripted.set(routeId, list);
636
+ },
637
+ tick() {
638
+ for (const s of services.values()) {
639
+ if (s.lifecycle === "UpdateInProgress") {
640
+ s.lifecycle = "Ready";
641
+ s.readyInstances = s.desiredInstances;
642
+ for (let i = 0; i < s.readyInstances; i++) {
643
+ const n = `${s.name}-${i}`;
644
+ if (!s.logs.has(n))
645
+ s.logs.set(n, [`${s.name} started`]);
646
+ }
647
+ }
648
+ else if (s.lifecycle === "NotDeployed") {
649
+ s.lifecycle = "UpdateInProgress";
650
+ }
651
+ }
652
+ },
653
+ appendLog(projectId, name, line, instance) {
654
+ const s = services.get(serviceKey(projectId, name));
655
+ if (!s)
656
+ throw new Error(`no service ${projectId}/${name}`);
657
+ const n = instance ?? `${name}-0`;
658
+ s.logs.set(n, [...(s.logs.get(n) ?? []), line]);
659
+ },
660
+ policy(creation, signupUrl) {
661
+ options.organizationCreation = creation;
662
+ options.signupUrl = signupUrl;
663
+ },
664
+ hideNewFromListings() {
665
+ hideNew = true;
666
+ },
667
+ settle() {
668
+ hideNew = false;
669
+ for (const o of organizations.values())
670
+ o.hidden = false;
671
+ for (const p of projects.values())
672
+ p.hidden = false;
673
+ },
674
+ state: { organizations, projects, services, tokens, tombstones },
675
+ seed(seed) {
676
+ for (const o of seed.organizations ?? []) {
677
+ const members = new Map();
678
+ for (const s of o.owners ?? [])
679
+ members.set(s, { role: "owner", since: now() });
680
+ for (const s of o.members ?? [])
681
+ members.set(s, { role: "member", since: now() });
682
+ organizations.set(o.id, { id: o.id, name: o.name, disabled: o.disabled ?? false, members, invitations: new Map(), hidden: false });
683
+ }
684
+ for (const p of seed.projects ?? [])
685
+ projects.set(p.id, { ...p, hidden: false });
686
+ for (const s of seed.services ?? []) {
687
+ const lifecycle = s.lifecycle ?? "Ready";
688
+ const n = s.instances ?? 1;
689
+ const ready = lifecycle === "Ready" ? n : 0;
690
+ const logs = new Map();
691
+ for (let i = 0; i < ready; i++)
692
+ logs.set(`${s.name}-${i}`, [`${s.name} started`]);
693
+ services.set(serviceKey(s.projectId, s.name), {
694
+ name: s.name,
695
+ projectId: s.projectId,
696
+ lifecycle,
697
+ generation: 1,
698
+ image: s.image ?? `${s.name}:latest`,
699
+ readyInstances: ready,
700
+ desiredInstances: n,
701
+ exposed: false,
702
+ paused: lifecycle === "Paused",
703
+ history: [{ kind: "applied", generation: 1, at: now() }],
704
+ logs,
705
+ previousLogs: new Map(),
706
+ });
707
+ }
708
+ },
709
+ close: () => new Promise((resolve) => {
710
+ server.closeAllConnections();
711
+ server.close(() => resolve());
712
+ }),
713
+ };
714
+ return fake;
715
+ }