@ever-works/oidc-identity 1.0.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/dist/index.js ADDED
@@ -0,0 +1,3244 @@
1
+ // src/oidc-identity.plugin.ts
2
+ import { randomBytes } from "crypto";
3
+
4
+ // ../../contracts/dist/index.js
5
+ var USER_SELECTABLE_WORK_KINDS = [
6
+ "website",
7
+ "landing-page",
8
+ "blog",
9
+ "directory",
10
+ "awesome-repo",
11
+ "repo",
12
+ "app"
13
+ ];
14
+ var WORK_KINDS = [...USER_SELECTABLE_WORK_KINDS, "company", "campaign", "default"];
15
+ var WORK_KIND_SET = new Set(WORK_KINDS);
16
+ var GOAL_KINDS = ["metric", "delivery"];
17
+ var GOAL_KIND_SET = new Set(GOAL_KINDS);
18
+ var APP_DEPENDENCY_BACKUP_OVERDUE_MS = 26 * 36e5;
19
+ var APP_DEPENDENCY_MANAGED = {
20
+ pgRoleConnectionLimit: 20,
21
+ pgDatabaseConnectionLimit: 25,
22
+ pgStatementTimeoutMs: 6e4,
23
+ pgIdleInTransactionTimeoutMs: 6e4,
24
+ bucketQuotaGiB: 10,
25
+ redisMaxMemoryMiB: 256,
26
+ backupMaxAgeMs: 864e5
27
+ };
28
+ var APP_ENV_VALIDATION_REFUSAL_CODES = [
29
+ "invalidName",
30
+ "reservedName",
31
+ "valueTooLarge",
32
+ "controlCharacter",
33
+ "lengthMismatch",
34
+ "tooShort",
35
+ "tooLong",
36
+ "patternMismatch"
37
+ ];
38
+ var APP_ENV_API_ERROR_CODES = [
39
+ "generatedValueUseRotate",
40
+ "neverRotateNotAcknowledged",
41
+ "rotateConfirmationMismatch",
42
+ "secureStorageUnavailable",
43
+ "tooManyValues",
44
+ "valuesTooLarge",
45
+ "malformedLine",
46
+ "rotateRateLimited"
47
+ ];
48
+ var APP_ENV_ERROR_CODES = [...APP_ENV_VALIDATION_REFUSAL_CODES, ...APP_ENV_API_ERROR_CODES];
49
+ var APP_LAUNCHER_CLIENT_CACHE_MS = 5 * 6e4;
50
+ var MANAGED_HOSTING_REASONS = [
51
+ "licenseNotGreen",
52
+ "upstreamAgreementMissing",
53
+ "entryDisallows",
54
+ "blueprintNotVerified",
55
+ "managedTierDisabled"
56
+ ];
57
+ var MANAGED_HOSTING_AVAILABILITIES = ["available", ...MANAGED_HOSTING_REASONS];
58
+ var APP_PRIVATE_COPY_MAX_SIZE_KB = 512e3;
59
+ var APP_DEPLOY_MAX_DURATION_S = 7200;
60
+ var APP_DEPLOY_LOCK_STALE_S = APP_DEPLOY_MAX_DURATION_S + 60;
61
+ var APPS_CATALOG_LAST_GOOD_REGISTRY_MS = 7 * 864e5;
62
+ var APP_REPOSITORY_STAGE_LIMITS = {
63
+ "private-copy": {
64
+ maxSizeKb: APP_PRIVATE_COPY_MAX_SIZE_KB,
65
+ fetchesLfs: false,
66
+ refusalCodes: ["too_large_for_private_copy", "uses_lfs"]
67
+ },
68
+ provisioning: {
69
+ maxSizeKb: 3 * 1024 * 1024,
70
+ refusalCodes: ["repository-too-large"]
71
+ },
72
+ build: {
73
+ refusalCodes: []
74
+ },
75
+ sandbox: {
76
+ refusalCodes: []
77
+ }
78
+ };
79
+ var TENANT_POSTGRES_ROLE_CONNECTION_LIMIT = APP_DEPENDENCY_MANAGED.pgRoleConnectionLimit;
80
+ var TENANT_POSTGRES_DATABASE_CONNECTION_LIMIT = APP_DEPENDENCY_MANAGED.pgDatabaseConnectionLimit;
81
+ var TENANT_POSTGRES_STATEMENT_TIMEOUT_MS = APP_DEPENDENCY_MANAGED.pgStatementTimeoutMs;
82
+ var TENANT_POSTGRES_IDLE_IN_TRANSACTION_TIMEOUT_MS = APP_DEPENDENCY_MANAGED.pgIdleInTransactionTimeoutMs;
83
+ var APP_PROVISION_LIMITS = {
84
+ attemptsDefault: 3,
85
+ attemptsMin: 1,
86
+ attemptsMax: 5,
87
+ attemptsPerAnswer: 2,
88
+ questionsMax: 3,
89
+ attemptsCeiling: 9,
90
+ optionsMax: 4,
91
+ tokenCapDefault: 3e6,
92
+ tokenCapMin: 5e5,
93
+ tokenCapMax: 1e7,
94
+ runnerMinuteCapDefault: 240,
95
+ runnerMinuteCapMin: 60,
96
+ runnerMinuteCapMax: 600,
97
+ /** Two starts of the same App Work inside this window yield one row (§4 "Start semantics"). */
98
+ startDedupeMs: 1e4,
99
+ analysisRunMs: 45 * 6e4,
100
+ iterateRunMs: 30 * 6e4,
101
+ forkWaitMs: 30 * 6e4,
102
+ activeDeadlineMs: 8 * 36e5,
103
+ /**
104
+ * Verification-Build reservation: `min(headSpec.build.resources.timeoutMinutes, buildMinutesMax)
105
+ * + runnerBootMinutesMax` — 90 minutes at the defaults, and the number §6.4 compares against the cap.
106
+ */
107
+ buildMinutesMax: 60,
108
+ jobSecondsMax: 900,
109
+ startupSecondsMax: 900,
110
+ smokeRequestMs: 3e4,
111
+ smokeTotalMs: 3e5,
112
+ runnerBootMinutesMax: 30,
113
+ runnerBootMemoryGiB: 12,
114
+ buildMemoryGiBMax: 14,
115
+ namespaceTtlMinutes: 90,
116
+ repoMaxBytes: 3 * 1024 ** 3,
117
+ diffMaxFiles: 12,
118
+ diffMaxLines: 3e3,
119
+ fileMaxBytes: 128 * 1024,
120
+ outputMaxBytes: 512 * 1024,
121
+ reportMaxChars: 4e4,
122
+ instructionFileMaxBytes: 32 * 1024,
123
+ logTailLines: 200,
124
+ logTailBytes: 16 * 1024,
125
+ evidenceCommentMaxChars: 6e4,
126
+ chatMessagesMax: 12,
127
+ questionReminderMs: 72 * 36e5,
128
+ questionExpiryMs: 14 * 864e5,
129
+ activePerUser: 3,
130
+ activePerOrg: 10,
131
+ startsPerHour: 10,
132
+ pollMs: 5e3,
133
+ infraRetries: 3,
134
+ infraRetryWindowMs: 30 * 6e4,
135
+ suggestionsPer30Days: 5,
136
+ autoReprovisionPerSync: 1,
137
+ autoReprovisionPer7Days: 2,
138
+ clusterProbeMs: 1e4,
139
+ podPendingInfraMs: 10 * 6e4
140
+ };
141
+ var MAX_CANDIDATE_OPTIONS = APP_PROVISION_LIMITS.optionsMax - 1;
142
+ var KB_EXPORT_MAX_BYTES = 200 * 1024 * 1024;
143
+ var TERMINAL_MAX_FRAME_BYTES = 1024 * 1024;
144
+ var COMPUTER_MAX_FRAME_BYTES = 512 * 1024;
145
+ var COMPUTER_MAX_BATCH_BYTES = 512 * 1024;
146
+ var COMPUTER_MAX_WIRE_FRAME_BYTES = Math.ceil(COMPUTER_MAX_FRAME_BYTES * 4 / 3) + 4096;
147
+ var COMPUTER_QUALITY_PRESETS = Object.freeze({
148
+ sharp: Object.freeze({ width: 1280, maxFps: 8, keyframeMs: 5e3, q: 70 }),
149
+ smooth: Object.freeze({ width: 960, maxFps: 15, keyframeMs: 5e3, q: 55 }),
150
+ steady: Object.freeze({ width: 800, maxFps: 2, keyframeMs: 2e3, q: 45 })
151
+ });
152
+ var COMPUTER_CONTROL_IDLE_MS_DEFAULT = 10 * 6e4;
153
+ var COMPUTER_CONTROL_CEILING_MS_DEFAULT = 60 * 6e4;
154
+ var COMPUTER_KEY_MODIFIER_ALT = 1;
155
+ var COMPUTER_KEY_MODIFIER_CTRL = 2;
156
+ var COMPUTER_KEY_MODIFIER_META = 4;
157
+ var CTRL = COMPUTER_KEY_MODIFIER_CTRL;
158
+ var META = COMPUTER_KEY_MODIFIER_META;
159
+ var ALT = COMPUTER_KEY_MODIFIER_ALT;
160
+ function withCtrlOrMeta(code, key) {
161
+ return [
162
+ { code, requires: CTRL, label: `Ctrl/\u2318+${key}` },
163
+ { code, requires: META, label: `Ctrl/\u2318+${key}` }
164
+ ];
165
+ }
166
+ var COMPUTER_BLOCKED_SHORTCUTS = Object.freeze([
167
+ ...withCtrlOrMeta("KeyC", "C"),
168
+ ...withCtrlOrMeta("KeyX", "X"),
169
+ ...withCtrlOrMeta("KeyV", "V"),
170
+ ...withCtrlOrMeta("KeyW", "W"),
171
+ ...withCtrlOrMeta("KeyT", "T"),
172
+ ...withCtrlOrMeta("KeyN", "N"),
173
+ ...withCtrlOrMeta("KeyQ", "Q"),
174
+ ...withCtrlOrMeta("Tab", "Tab"),
175
+ { code: "Tab", requires: ALT, label: "Alt+Tab" },
176
+ { code: "F4", requires: ALT, label: "Alt+F4" },
177
+ { code: "Delete", requires: CTRL | ALT, label: "Ctrl+Alt+Delete" },
178
+ { code: "MetaLeft", requires: 0, label: "\u2318/Win" },
179
+ { code: "MetaRight", requires: 0, label: "\u2318/Win" }
180
+ ]);
181
+ var TASK_BOARD_STATUSES = [
182
+ "backlog",
183
+ "todo",
184
+ "in_progress",
185
+ "in_review",
186
+ "blocked",
187
+ "done",
188
+ "cancelled"
189
+ ];
190
+ var TASK_BOARD_TERMINAL_STATUSES = ["done", "cancelled"];
191
+ var TASK_BOARD_STATUS_COLUMNS = TASK_BOARD_STATUSES.map((status) => ({
192
+ key: status,
193
+ statuses: [status],
194
+ terminal: TASK_BOARD_TERMINAL_STATUSES.includes(status),
195
+ toggleOnly: false
196
+ }));
197
+ var PLATFORM_DEFAULT_MERGE_POLICY = Object.freeze({
198
+ allowAgentMerge: false,
199
+ requireGreenGate: true,
200
+ requireHumanApproval: true,
201
+ allowedMergeMethods: ["squash"],
202
+ protectedBranches: ["main", "master", "develop", "stage"]
203
+ });
204
+ var MERGE_APPROVAL_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
205
+ var TOOL_GRANT_SCOPE_PRECEDENCE = Object.freeze([
206
+ "tenant",
207
+ "organization",
208
+ "work",
209
+ "agent"
210
+ ]);
211
+ var PLATFORM_DEFAULT_TOOL_GRANT = Object.freeze({
212
+ allow: Object.freeze(["*"]),
213
+ deny: Object.freeze([])
214
+ });
215
+ var AGENT_INIT_SCRIPT_MAX_BYTES = 16 * 1024;
216
+ var INGEST_EVENT_PAYLOAD_MAX_BYTES = 32 * 1024;
217
+ var VERSION = "1.0.0";
218
+ var GTM_SKILLS = [
219
+ // ── research ────────────────────────────────────────────────────────
220
+ {
221
+ slug: "lead-research",
222
+ title: "Lead research",
223
+ description: "Build a candidate lead list from seed criteria and public sources, recording a supporting source for every contact.",
224
+ stage: "research",
225
+ tags: ["gtm", "sales", "research"],
226
+ version: VERSION,
227
+ inputs: [
228
+ { key: "icp", description: "Ideal-customer description: segment, size, geography, role titles." },
229
+ { key: "seed_sources", description: "Seed lists, domains, or saved searches to start from." },
230
+ { key: "max_leads", description: "Hard cap on how many candidates this run may collect." }
231
+ ],
232
+ outputs: [
233
+ { key: "contacts", description: "Candidate contacts with name, company, title, and a source reference." }
234
+ ],
235
+ allowedTools: ["search", "content-extractor"],
236
+ body: [
237
+ "Build a candidate lead list from the supplied criteria and seed sources.",
238
+ "",
239
+ "## Rules",
240
+ "- NEVER invent a contact detail. An email address, phone number, or title is recorded only",
241
+ " when a source supports it; otherwise leave the field empty.",
242
+ "- Record `source` on every contact \u2014 the URL or seed list the entry came from. A contact",
243
+ " without a source is not a lead, it is a guess.",
244
+ "- Respect `max_leads` exactly. Stop collecting when the cap is reached and say how many",
245
+ " candidates were left unexamined.",
246
+ "- De-duplicate by email when present, otherwise by (company, name).",
247
+ "- Exclude anyone who has asked not to be contacted, and any source that forbids collection.",
248
+ "",
249
+ "## Output",
250
+ "Write `contacts`. Each entry carries `name`, and any of `email`, `company`, `title`,",
251
+ "`source`, `notes` that a source supports. Report the counts examined, kept, and dropped,",
252
+ "with the reason for each drop category."
253
+ ].join("\n")
254
+ },
255
+ {
256
+ slug: "competitor-watch",
257
+ title: "Competitor watch",
258
+ description: "Monitor a watchlist of companies across public sources and emit dated, source-linked change signals.",
259
+ stage: "research",
260
+ tags: ["gtm", "marketing", "research"],
261
+ version: VERSION,
262
+ inputs: [
263
+ { key: "watchlist", description: "Companies to monitor: name, website, public repositories." },
264
+ { key: "focus_areas", description: "What to watch: pricing, features, positioning, hiring." },
265
+ { key: "since", description: "Only report changes observed after this date." }
266
+ ],
267
+ outputs: [{ key: "signals", description: "Dated observations with a source URL and the focus area they hit." }],
268
+ allowedTools: ["search", "content-extractor"],
269
+ body: [
270
+ "Monitor the watchlist across public sources and report what changed.",
271
+ "",
272
+ "## Rules",
273
+ "- Public sources only: published pages, public announcements, public repositories.",
274
+ "- Every signal carries a source URL and an observation date. No date or no URL means the",
275
+ " signal is not reportable.",
276
+ "- Separate FACT (what the source says) from ANALYSIS (what you infer). Never blend them in",
277
+ " the same sentence.",
278
+ "- Report only changes since `since`. If a focus area produced nothing, say so explicitly \u2014",
279
+ " an empty period is a finding, not a gap to fill.",
280
+ "- Do not speculate about unannounced plans, internal metrics, or private information.",
281
+ "",
282
+ "## Output",
283
+ "Write `signals`. Each entry carries `title`, `url`, `publishedDate`, the focus area, and a",
284
+ "one-line factual summary."
285
+ ].join("\n")
286
+ },
287
+ {
288
+ slug: "news-signal-detection",
289
+ title: "News signal detection",
290
+ description: "Scan configured topics for newsworthy events and rank them by relevance to the campaign, discarding noise.",
291
+ stage: "research",
292
+ tags: ["gtm", "marketing", "research"],
293
+ version: VERSION,
294
+ inputs: [
295
+ { key: "topics", description: "Topics, keywords, or accounts to scan." },
296
+ { key: "lookback_days", description: "How far back the scan reaches." },
297
+ { key: "relevance_threshold", description: "Minimum relevance (0-1) an item must clear to be kept." }
298
+ ],
299
+ outputs: [{ key: "signals", description: "Relevance-ranked news items with source URL and publish date." }],
300
+ allowedTools: ["search", "content-extractor"],
301
+ body: [
302
+ "Scan the configured topics and keep only what is genuinely newsworthy for this campaign.",
303
+ "",
304
+ "## Rules",
305
+ "- Score each candidate 0-1 for relevance to the campaign brief and drop anything below",
306
+ " `relevance_threshold`. State the score and the one-line reason for every item you keep.",
307
+ "- Prefer primary sources over aggregators; when both exist, link the primary.",
308
+ "- Collapse duplicate coverage of the same event into one signal with the best source.",
309
+ "- Nothing older than `lookback_days`. Undated items are dropped, not guessed at.",
310
+ "- Never restate a headline as fact when the source hedges it \u2014 carry the hedge through.",
311
+ "",
312
+ "## Output",
313
+ "Write `signals` ordered by descending relevance, each with `title`, `url`, `publishedDate`,",
314
+ "the relevance score, and the reason it matters here."
315
+ ].join("\n")
316
+ },
317
+ // ── qualify ─────────────────────────────────────────────────────────
318
+ {
319
+ slug: "lead-scoring",
320
+ title: "Lead scoring",
321
+ description: "Score contacts 0-100 against a declarative weight table and attach an explainable reason per point awarded.",
322
+ stage: "qualify",
323
+ tags: ["gtm", "sales", "scoring"],
324
+ version: VERSION,
325
+ inputs: [
326
+ { key: "contacts", description: "Candidate contacts to score." },
327
+ { key: "score_weights", description: "Weight table: signal name \u2192 points awarded." }
328
+ ],
329
+ outputs: [
330
+ { key: "scored_contacts", description: "Contacts with `score` (0-100) and `scoreReasons` per contact." }
331
+ ],
332
+ allowedTools: [],
333
+ body: [
334
+ "Score every contact against the supplied weight table.",
335
+ "",
336
+ "## Rules",
337
+ "- The weight table is the whole model. Award points ONLY for signals it names; never invent",
338
+ " a bonus, and never award points for a signal the contact data does not actually support.",
339
+ "- Clamp the final score to 0-100. Record one `scoreReasons` line per point awarded, in the",
340
+ " form `<signal> +<points>`, so any score can be re-derived by hand.",
341
+ "- A missing field is worth zero points, never an assumed value.",
342
+ "- Scoring is deterministic: the same contact and the same weight table must always produce",
343
+ " the same score. Do not break ties with judgement calls \u2014 leave ties tied.",
344
+ "",
345
+ "## Output",
346
+ "Write `scored_contacts` sorted by descending score, then report the score distribution and",
347
+ "the three signals that moved the list most."
348
+ ].join("\n")
349
+ },
350
+ {
351
+ slug: "risk-filter",
352
+ title: "Risk filter",
353
+ description: "Flag low-quality or unsafe contacts against a risk weight table and exclude those at or above the threshold.",
354
+ stage: "qualify",
355
+ tags: ["gtm", "sales", "safety"],
356
+ version: VERSION,
357
+ inputs: [
358
+ { key: "scored_contacts", description: "Contacts already carrying a priority score." },
359
+ { key: "risk_weights", description: "Risk weight table: risk signal \u2192 points." },
360
+ { key: "risk_threshold", description: "Risk score at or above which a contact is excluded." }
361
+ ],
362
+ outputs: [
363
+ {
364
+ key: "scored_contacts",
365
+ description: "The same contacts, now carrying `riskScore`, `riskReasons`, and an excluded flag."
366
+ }
367
+ ],
368
+ allowedTools: [],
369
+ body: [
370
+ "Assess each scored contact for risk and mark \u2014 never silently delete \u2014 the excluded ones.",
371
+ "",
372
+ "## Rules",
373
+ "- Apply the risk weight table exactly as given; record one `riskReasons` line per point.",
374
+ "- Contacts at or above `risk_threshold` are EXCLUDED from outbound, but they stay in the",
375
+ " data set with their reasons attached so a human can overturn the call.",
376
+ '- Never drop a contact without a recorded reason. "Looked wrong" is not a reason.',
377
+ "- Risk is about deliverability and safety (unverifiable identity, throwaway domains,",
378
+ " do-not-contact signals) \u2014 it is not a second opinion on fit. Fit is `lead-scoring`.",
379
+ "",
380
+ "## Output",
381
+ "Write the enriched `scored_contacts` and report how many were excluded, grouped by the",
382
+ "dominant risk reason."
383
+ ].join("\n")
384
+ },
385
+ // ── draft ───────────────────────────────────────────────────────────
386
+ {
387
+ slug: "outreach-personalization",
388
+ title: "Outreach personalization",
389
+ description: "Write one 80-120 word personalized outbound draft per qualified contact, grounded only in known facts.",
390
+ stage: "draft",
391
+ tags: ["gtm", "sales", "writing"],
392
+ version: VERSION,
393
+ inputs: [
394
+ { key: "scored_contacts", description: "Qualified, non-excluded contacts to write for." },
395
+ { key: "campaign_brief", description: "Offer, proof points, and the single ask." },
396
+ { key: "tone", description: "Configured tone for the campaign." }
397
+ ],
398
+ outputs: [{ key: "drafts", description: "One draft per contact with `ref`, `channel`, `subject`, `body`." }],
399
+ allowedTools: [],
400
+ body: [
401
+ "Write one outbound draft per qualified contact.",
402
+ "",
403
+ "## Rules",
404
+ "- HARD CONSTRAINT: drafts only. Nothing is sent, scheduled, or queued for delivery here.",
405
+ '- 80-120 words in the body. One ask. No multi-part asks, no "quick question" openers that',
406
+ " the rest of the message contradicts.",
407
+ "- Personalize ONLY from fields and signals actually present on the contact. If there is no",
408
+ " personalization material, write the generic version and say so \u2014 a fabricated detail is",
409
+ " worse than a generic opener.",
410
+ "- Write a subject line only for channels that carry one.",
411
+ "- No invented metrics, customer names, or mutual connections. Ever.",
412
+ "",
413
+ "## Output",
414
+ "Write `drafts`, ordered by descending contact score so the review gate sees the best",
415
+ "candidates first. Each draft carries a stable `ref` used later by review and the action log."
416
+ ].join("\n")
417
+ },
418
+ {
419
+ slug: "newsletter-drafting",
420
+ title: "Newsletter drafting",
421
+ description: "Turn briefs, notes, and collected signals into a scannable newsletter issue with one clear call to action.",
422
+ stage: "draft",
423
+ tags: ["gtm", "marketing", "content", "writing"],
424
+ version: VERSION,
425
+ inputs: [
426
+ { key: "campaign_brief", description: "Issue theme, audience, and the call to action." },
427
+ { key: "signals", description: "Collected source material for the issue." },
428
+ { key: "tone", description: "Configured editorial tone." }
429
+ ],
430
+ outputs: [{ key: "drafts", description: "A single newsletter draft: subject line plus sectioned body." }],
431
+ allowedTools: ["content-extractor"],
432
+ body: [
433
+ "Draft one newsletter issue from the brief and the collected material.",
434
+ "",
435
+ "## Rules",
436
+ "- Draft-first: the issue is produced for human review. Never send or schedule it.",
437
+ "- Structure: subject line, one-sentence hook, 3-5 short sections, one call to action.",
438
+ " Scannable beats comprehensive.",
439
+ "- Every claim traces to the brief, the knowledge base, or a supplied signal. Link the",
440
+ " source when the section rests on one.",
441
+ "- Never invent metrics, quotes, or customer names.",
442
+ "- When the source material is thin, say so and list what extra input would raise quality",
443
+ " rather than padding the issue.",
444
+ "",
445
+ "## Output",
446
+ "Write `drafts` with a single entry: `subject` plus the sectioned `body`."
447
+ ].join("\n")
448
+ },
449
+ {
450
+ slug: "social-scheduling",
451
+ title: "Social scheduling",
452
+ description: "Plan a channel-fit social calendar and stage each post for review, respecting cadence and angle variety.",
453
+ stage: "draft",
454
+ tags: ["gtm", "marketing", "social", "writing"],
455
+ version: VERSION,
456
+ inputs: [
457
+ { key: "campaign_brief", description: "Campaign themes, channels, and cadence." },
458
+ { key: "signals", description: "Source material and trends to build posts from." },
459
+ { key: "cadence", description: "Posts per channel per period." }
460
+ ],
461
+ outputs: [
462
+ { key: "drafts", description: "Channel-fit post drafts, each with its planned slot in the calendar." }
463
+ ],
464
+ allowedTools: [],
465
+ body: [
466
+ "Plan the social calendar for the cadence window and draft each post.",
467
+ "",
468
+ "## Rules",
469
+ "- Draft-first: posts are STAGED for review. Never publish, never schedule to a live channel.",
470
+ "- Fit each draft to its channel: length, hashtag conventions, and link placement differ and",
471
+ " a cross-posted body is a tell.",
472
+ "- Do not repeat the same angle inside one cadence window. Track the angle per slot and vary",
473
+ " it deliberately.",
474
+ "- Build only from the brief, the content Works, and supplied signals. Never invent product",
475
+ " claims or engagement numbers.",
476
+ "- Respect `cadence` exactly \u2014 proposing more slots than the operator configured is not",
477
+ " ambition, it is a spam risk.",
478
+ "",
479
+ "## Output",
480
+ "Write `drafts` with the planned slot (channel + relative day) on each entry, plus a compact",
481
+ "calendar view of the window."
482
+ ].join("\n")
483
+ },
484
+ {
485
+ slug: "digest-compilation",
486
+ title: "Digest compilation",
487
+ description: "Compile collected signals into a recurring digest that separates facts from analysis and highlights change.",
488
+ stage: "draft",
489
+ tags: ["gtm", "marketing", "reporting", "writing"],
490
+ version: VERSION,
491
+ inputs: [
492
+ { key: "signals", description: "Signals collected this period." },
493
+ { key: "previous_digest", description: "The prior issue, used to compute what changed." },
494
+ { key: "focus_areas", description: "Sections the digest must cover." }
495
+ ],
496
+ outputs: [{ key: "drafts", description: "A digest draft: sections, change highlights, and a trend view." }],
497
+ allowedTools: [],
498
+ body: [
499
+ "Compile the period digest from the collected signals.",
500
+ "",
501
+ "## Rules",
502
+ "- One section per focus area, in the configured order, every issue. A stable shape is what",
503
+ " makes a recurring digest readable.",
504
+ "- Every section splits FACTS (sourced, dated, linked) from ANALYSIS (your interpretation),",
505
+ " under those labels.",
506
+ "- Lead with what CHANGED since `previous_digest`. Unchanged areas get one line, not a",
507
+ " restatement of last issue.",
508
+ "- No fabrication and no padding: if a focus area produced no signal this period, write",
509
+ " exactly that.",
510
+ "- Close with a short trend view over the recent periods \u2014 direction, not prophecy.",
511
+ "",
512
+ "## Output",
513
+ "Write `drafts` with a single digest entry for review."
514
+ ].join("\n")
515
+ },
516
+ // ── act ─────────────────────────────────────────────────────────────
517
+ {
518
+ slug: "crm-sync-hygiene",
519
+ title: "CRM sync hygiene",
520
+ description: "Prepare reviewed records for a customer-record sync: normalized fields, no duplicates, no destructive overwrites.",
521
+ stage: "act",
522
+ tags: ["gtm", "sales", "operations"],
523
+ version: VERSION,
524
+ inputs: [
525
+ { key: "approved_drafts", description: "Records cleared by the review gate." },
526
+ { key: "field_map", description: "Local field \u2192 destination field mapping." }
527
+ ],
528
+ outputs: [
529
+ { key: "action_log", description: "Prepared record writes with the field diff and the reason for each." }
530
+ ],
531
+ allowedTools: ["connector"],
532
+ body: [
533
+ "Prepare reviewed records for the destination system without losing data.",
534
+ "",
535
+ "## Rules",
536
+ "- Only records that passed the review gate are eligible. Nothing else, no exceptions.",
537
+ "- NEVER overwrite a populated destination field with an empty local value. Blank does not",
538
+ ' mean "clear it", it means "we do not know".',
539
+ "- Match before you create: search by the destination key (email, then domain + name) and",
540
+ " update the existing record rather than minting a duplicate.",
541
+ "- Normalize on the way out \u2014 casing, phone format, domain form \u2014 per `field_map`.",
542
+ "- Record every intended write in `action_log` with its field-level diff BEFORE it is",
543
+ " applied, so the change is auditable and reversible.",
544
+ "",
545
+ "## Output",
546
+ "Write `action_log` with one entry per record, status `prepared` or `skipped`, and the",
547
+ "reason for every skip."
548
+ ].join("\n")
549
+ },
550
+ // ── follow-up ───────────────────────────────────────────────────────
551
+ {
552
+ slug: "follow-up-cadence",
553
+ title: "Follow-up cadence",
554
+ description: "Schedule timed re-engagement for quiet threads, with strict caps and an unconditional stop on any reply.",
555
+ stage: "follow-up",
556
+ tags: ["gtm", "sales", "cadence"],
557
+ version: VERSION,
558
+ inputs: [
559
+ { key: "action_log", description: "What was prepared, for whom, and when." },
560
+ { key: "reply_state", description: "Which threads have received a reply." },
561
+ { key: "cadence", description: "Follow-up spacing and the maximum number of touches." }
562
+ ],
563
+ outputs: [
564
+ { key: "follow_up_queue", description: "Queued follow-ups with a due offset and the rationale for each." }
565
+ ],
566
+ allowedTools: [],
567
+ body: [
568
+ "Queue follow-ups for threads that have gone quiet.",
569
+ "",
570
+ "## Rules",
571
+ "- HARD STOP on any reply, bounce, or opt-out: that thread leaves the cadence immediately and",
572
+ " permanently. This overrides every other rule here.",
573
+ "- Never exceed the configured maximum touches per contact, and never shorten the configured",
574
+ " spacing to fit more in.",
575
+ "- Each follow-up must add something \u2014 a new angle, a new proof point, a genuine deadline.",
576
+ ' "Bumping this to the top of your inbox" is not a follow-up.',
577
+ "- Follow-ups are drafts like any other outbound: they re-enter the review gate before",
578
+ " anything is prepared for delivery.",
579
+ "",
580
+ "## Output",
581
+ "Write `follow_up_queue` with `draftRef`, `channel`, `dueAfterDays`, and the rationale."
582
+ ].join("\n")
583
+ },
584
+ {
585
+ slug: "reply-detection",
586
+ title: "Reply detection",
587
+ description: "Classify inbound replies into interested / not-now / decline / opt-out and route each to the right next step.",
588
+ stage: "follow-up",
589
+ tags: ["gtm", "sales", "classification"],
590
+ version: VERSION,
591
+ inputs: [
592
+ { key: "inbound_messages", description: "Replies received against prepared outbound." },
593
+ { key: "action_log", description: "The outbound each reply responds to." }
594
+ ],
595
+ outputs: [
596
+ { key: "reply_state", description: "Per-thread classification, confidence, and the routing decision." }
597
+ ],
598
+ allowedTools: ["connector"],
599
+ body: [
600
+ "Classify each inbound reply and decide what happens to its thread.",
601
+ "",
602
+ "## Classes",
603
+ "`interested` \xB7 `not_now` \xB7 `decline` \xB7 `opt_out` \xB7 `auto_reply` \xB7 `unclear`",
604
+ "",
605
+ "## Rules",
606
+ "- `opt_out` and `decline` stop the cadence permanently. When in doubt between `opt_out` and",
607
+ " anything else, choose `opt_out` \u2014 the cost of a wrong stop is far below the cost of a",
608
+ " wrong send.",
609
+ "- `auto_reply` (out-of-office, autoresponder) does NOT count as a reply for cadence",
610
+ " purposes, but it does update the contact record if it names a new owner.",
611
+ "- Use `unclear` rather than guessing; unclear threads route to a human, they do not route",
612
+ " to the next touch.",
613
+ "- Record a confidence value and the phrase that drove the classification.",
614
+ "",
615
+ "## Output",
616
+ "Write `reply_state` with the class, confidence, evidence phrase, and routing decision per",
617
+ "thread."
618
+ ].join("\n")
619
+ },
620
+ // ── enrich ──────────────────────────────────────────────────────────
621
+ {
622
+ slug: "contact-enrichment",
623
+ title: "Contact enrichment",
624
+ description: "Backfill missing contact and account fields from verifiable sources, recording the evidence for each fill.",
625
+ stage: "enrich",
626
+ tags: ["gtm", "sales", "data"],
627
+ version: VERSION,
628
+ inputs: [
629
+ { key: "contacts", description: "Contacts with gaps to fill." },
630
+ { key: "enrichment_fields", description: "Which fields this run is allowed to fill." }
631
+ ],
632
+ outputs: [
633
+ { key: "enriched_contacts", description: "Contacts with filled fields and a source note per filled field." }
634
+ ],
635
+ allowedTools: ["search", "content-extractor", "connector"],
636
+ body: [
637
+ "Fill the requested gaps on each contact \u2014 and only those gaps.",
638
+ "",
639
+ "## Rules",
640
+ "- Evidence-bound: a field is filled ONLY when a source supports it, and the source goes in",
641
+ " the contact notes alongside the value. No source, no fill.",
642
+ "- NEVER guess or pattern-generate an email address. Constructing `first.last@domain` because",
643
+ " it usually works is fabrication, and it is the single most damaging thing this Skill can do.",
644
+ "- Never overwrite an existing non-empty value. Enrichment fills gaps; corrections are a",
645
+ " human decision.",
646
+ "- Only touch fields named in `enrichment_fields`.",
647
+ "- When two sources disagree, leave the field empty and flag the conflict.",
648
+ "",
649
+ "## Output",
650
+ "Write `enriched_contacts` and report fill rate per field plus every conflict flagged."
651
+ ].join("\n")
652
+ },
653
+ // ── measure ─────────────────────────────────────────────────────────
654
+ {
655
+ slug: "seo-audit",
656
+ title: "Search-visibility audit",
657
+ description: "Audit a site or content Work for search visibility and return a prioritized, evidence-cited fix list.",
658
+ stage: "measure",
659
+ tags: ["gtm", "marketing", "seo", "audit"],
660
+ version: VERSION,
661
+ inputs: [
662
+ { key: "pages", description: "Pages or posts in scope, with their current metadata and headings." },
663
+ { key: "target_keywords", description: "Keywords or topics the Work is meant to rank for." },
664
+ { key: "previous_audit", description: "The prior audit, used to report movement and regressions." }
665
+ ],
666
+ outputs: [
667
+ { key: "campaign_report", description: "Prioritized findings with page reference, impact, and fix." }
668
+ ],
669
+ allowedTools: ["content-extractor", "search"],
670
+ body: [
671
+ "Audit the in-scope pages for search visibility and propose fixes.",
672
+ "",
673
+ "## What to check",
674
+ "Title and description metadata \xB7 heading structure and hierarchy \xB7 internal linking and",
675
+ "orphan pages \xB7 keyword coverage against `target_keywords` and the gaps between them \xB7",
676
+ "duplicate or thin content \xB7 canonical and indexability signals.",
677
+ "",
678
+ "## Rules",
679
+ "- Every finding cites the specific page or setting it applies to and states the expected",
680
+ " impact. A finding without a page reference is an opinion.",
681
+ "- Return a prioritized top 5-10, quick wins first \u2014 not an exhaustive dump.",
682
+ "- Propose changes as reviewable edits or tasks. Never modify published pages directly.",
683
+ "- When `previous_audit` is present, report movement honestly, INCLUDING regressions. A",
684
+ " clean report that hides a regression is worse than no report.",
685
+ "- No traffic or ranking projections. Estimate effort and impact qualitatively instead.",
686
+ "",
687
+ "## Output",
688
+ "Write `campaign_report` with the prioritized findings and the movement summary."
689
+ ].join("\n")
690
+ },
691
+ {
692
+ slug: "campaign-reporting",
693
+ title: "Campaign reporting",
694
+ description: "Summarize a campaign period into counted totals, honest insights, and concrete hints for the next cycle.",
695
+ stage: "measure",
696
+ tags: ["gtm", "reporting"],
697
+ version: VERSION,
698
+ inputs: [
699
+ { key: "action_log", description: "What the campaign prepared and skipped." },
700
+ { key: "reply_state", description: "How recipients responded." },
701
+ { key: "scored_contacts", description: "The qualified population the period worked from." }
702
+ ],
703
+ outputs: [{ key: "campaign_report", description: "Totals, insights, and next-cycle variant hints." }],
704
+ allowedTools: [],
705
+ body: [
706
+ "Report the campaign period and close the loop into the next one.",
707
+ "",
708
+ "## Rules",
709
+ "- Totals are COUNTED from the supplied data, never estimated: contacts, qualified, excluded,",
710
+ " drafts, approved, prepared, follow-ups queued.",
711
+ "- State the denominator on every rate. A percentage without its base is not a metric.",
712
+ "- Below roughly 30 observations, report counts and refuse to draw a conclusion \u2014 small",
713
+ " samples produce confident nonsense.",
714
+ "- Insights must be falsifiable and tied to a number in the totals.",
715
+ "- End with `nextVariantHints`: concrete, testable changes for the next draft cycle, one per",
716
+ " line. This is the loop closing \u2014 a report that ends at description wasted the period.",
717
+ "",
718
+ "## Output",
719
+ "Write `campaign_report` with `summary`, `totals`, `insights`, and `nextVariantHints`."
720
+ ].join("\n")
721
+ },
722
+ {
723
+ slug: "engagement-analysis",
724
+ title: "Engagement analysis",
725
+ description: "Compare engagement across variants, channels, and segments, and say which differences are real.",
726
+ stage: "measure",
727
+ tags: ["gtm", "marketing", "reporting"],
728
+ version: VERSION,
729
+ inputs: [
730
+ { key: "engagement_events", description: "Per-item engagement observations for the period." },
731
+ { key: "drafts", description: "The variants the events belong to." },
732
+ { key: "comparison_window", description: "Prior period to compare against." }
733
+ ],
734
+ outputs: [
735
+ { key: "campaign_report", description: "Per-variant and per-segment engagement with a confidence call." }
736
+ ],
737
+ allowedTools: ["metrics"],
738
+ body: [
739
+ "Analyze engagement and report what actually differs.",
740
+ "",
741
+ "## Rules",
742
+ "- Compare like with like: same channel, same audience segment, comparable send window.",
743
+ " Cross-channel comparisons need an explicit caveat.",
744
+ "- Report absolute counts alongside every rate.",
745
+ "- Say plainly when a difference is within noise. Declaring a winner on a handful of",
746
+ " observations is the most common way this analysis misleads a campaign for months.",
747
+ "- Separate what changed (observation) from why (hypothesis), and label hypotheses as such.",
748
+ "- Surface the losing variants too \u2014 a variant that consistently underperforms is the",
749
+ " cheapest thing to stop doing.",
750
+ "",
751
+ "## Output",
752
+ "Write `campaign_report` with the per-variant table, the segment breakdown, and an explicit",
753
+ "confidence statement per comparison."
754
+ ].join("\n")
755
+ }
756
+ ];
757
+ var GTM_SKILL_SLUGS = GTM_SKILLS.map((skill) => skill.slug);
758
+ var GTM_SKILL_BY_SLUG = new Map(
759
+ GTM_SKILLS.map((skill) => [skill.slug, skill])
760
+ );
761
+ var SKILL_READINESS_STATES = [
762
+ "ready",
763
+ "needs_setup",
764
+ "missing_requirements",
765
+ "blocked_by_access",
766
+ "unknown",
767
+ "check_failed"
768
+ ];
769
+ var SKILL_CARD_STATES = [...SKILL_READINESS_STATES, "disabled", "needs_review"];
770
+ var SKILL_READINESS_FILTERS = [...SKILL_CARD_STATES, "attention"];
771
+ var FLEET_AGENT_CREDENTIAL_FAMILIES = [
772
+ {
773
+ cli: "claude-code",
774
+ // CLAUDE_CODE_OAUTH_TOKEN (a Claude plan) before ANTHROPIC_API_KEY
775
+ // (per-token Console billing) — deliberately the REVERSE of the CLI's
776
+ // own precedence, so the cheaper credential wins rather than the one
777
+ // that silently spends money.
778
+ envNames: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
779
+ },
780
+ {
781
+ cli: "codex",
782
+ // CODEX_ACCESS_TOKEN draws the ChatGPT workspace entitlement;
783
+ // OPENAI_API_KEY is per-token platform billing.
784
+ envNames: ["CODEX_ACCESS_TOKEN", "OPENAI_API_KEY"]
785
+ }
786
+ ];
787
+ var FLEET_AGENT_CREDENTIAL_ENV_NAMES = FLEET_AGENT_CREDENTIAL_FAMILIES.flatMap(
788
+ (family) => family.envNames
789
+ );
790
+ var FLEET_RUN_ENV_FILE_MAX_CONTENT_BYTES = 32 * 1024;
791
+ var FLEET_RUN_ENV_FILES_MAX_TOTAL_BYTES = 256 * 1024;
792
+ var FLEET_JOB_MAX_PAYLOAD_BYTES = 256 * 1024;
793
+ var FLEET_JOB_MAX_RESULT_BYTES = 256 * 1024;
794
+ var FLEET_JOB_DEFAULT_QUEUED_MAX_AGE_SEC = Object.freeze({
795
+ "agent-task": 24 * 60 * 60,
796
+ "acceptance-checks": 2 * 60 * 60,
797
+ "browser-check": 2 * 60 * 60,
798
+ // A live view nobody claimed is abandoned by the session service after
799
+ // 40 seconds, which cancels its job there and then. The queue SLA is only
800
+ // the backstop behind that, so it deliberately does not undercut the
801
+ // shortest existing default (the fallback an unknown kind fails closed to).
802
+ "computer-session": 2 * 60 * 60
803
+ });
804
+ var FLEET_JOB_MAX_QUEUED_MAX_AGE_SEC = 7 * 24 * 60 * 60;
805
+ var FLEET_AGENT_EXECUTION_MAX_INSTRUCTIONS_BYTES = 160 * 1024;
806
+ var FLEET_AGENT_TASK_QUESTION_MAX_FILE_BYTES = 64 * 1024;
807
+ var FLEET_AGENT_TASK_QUESTION_MAX_CONTEXT_BYTES = 6 * 1024;
808
+ var FLEET_MAX_DAILY_COST_CEILING_CENTS = 1e5 * 100;
809
+ var FLEET_MAX_DISK_FREE_BYTES = 2 ** 60;
810
+ var FLEET_DEFAULT_ENROLLMENT_TOKEN_TTL_MS = 15 * 6e4;
811
+ var FLEET_DEFAULT_NODE_OFFLINE_AFTER_MS = 5 * 6e4;
812
+ var FLEET_DEFAULT_NODE_OFFLINE_NOTICE_AFTER_MS = 30 * 6e4;
813
+ var FLEET_DEFAULT_CREDENTIAL_ROTATION_OVERLAP_MS = 15 * 6e4;
814
+ var FLEET_MAX_CREDENTIAL_ROTATION_OVERLAP_MS = 24 * 60 * 6e4;
815
+ var EMAIL_INBOX_DEFAULT_DAILY_CAP = 100;
816
+ var EMAIL_INBOX_BURST_SENDS = 10;
817
+ var EMAIL_INBOX_BURST_RECIPIENTS = 20;
818
+ var EMAIL_WORKSPACE_DAILY_CAP = 500;
819
+ var EMAIL_WORKSPACE_MONTHLY_CAP = 1e4;
820
+ var EMAIL_MAX_RECIPIENTS_PER_MESSAGE = 50;
821
+ var EMAIL_SEND_CAP_RECOMMENDED_DEFAULTS = Object.freeze({
822
+ inboxDailySends: EMAIL_INBOX_DEFAULT_DAILY_CAP,
823
+ inboxBurstSends: EMAIL_INBOX_BURST_SENDS,
824
+ inboxBurstRecipients: EMAIL_INBOX_BURST_RECIPIENTS,
825
+ recipientsPerMessage: EMAIL_MAX_RECIPIENTS_PER_MESSAGE,
826
+ workspaceDailySends: EMAIL_WORKSPACE_DAILY_CAP,
827
+ workspaceMonthlySends: EMAIL_WORKSPACE_MONTHLY_CAP
828
+ });
829
+ var EMAIL_DAY_WINDOW_MS = 24 * 60 * 60 * 1e3;
830
+ var EMAIL_MONTH_WINDOW_MS = 30 * EMAIL_DAY_WINDOW_MS;
831
+ var EMAIL_INBOX_CAP_FIELDS = [
832
+ "inboxDailySends",
833
+ "inboxBurstSends",
834
+ "inboxBurstRecipients",
835
+ "recipientsPerMessage"
836
+ ];
837
+ var EMAIL_SEND_CAP_FIELDS = [
838
+ ...EMAIL_INBOX_CAP_FIELDS,
839
+ "workspaceDailySends",
840
+ "workspaceMonthlySends"
841
+ ];
842
+ var INBOX_CAP_FIELD_SET = new Set(EMAIL_INBOX_CAP_FIELDS);
843
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
844
+ var PROMOTION_GATE_DECISION_GRACE_MS = 20 * 60 * 1e3;
845
+ var RELEASE_VERIFY_ATTEMPT_INTERVAL_MS = 10 * 60 * 1e3;
846
+ var RELEASE_VERIFY_BUDGET_MS = 8 * 60 * 60 * 1e3;
847
+ var MAX_CONVERSATION_BODY_BYTES = 16 * 1024;
848
+ var CONNECTION_CREDENTIAL_ERROR_CODES = Object.freeze([
849
+ "credential_rejected",
850
+ "credential_missing",
851
+ "https_required"
852
+ ]);
853
+ var CONNECTION_HEALTH_WARNING_CODES = Object.freeze([
854
+ "insecure_transport"
855
+ ]);
856
+ var BACKUP_TRIM_POLICIES = Object.freeze({
857
+ activity: Object.freeze({ field: "createdAt", defaultDays: 365, fullHistoryDays: 1095 }),
858
+ runLogs: Object.freeze({ field: "createdAt", defaultDays: 90, fullHistoryDays: 1095 }),
859
+ terminalTranscripts: Object.freeze({ field: "createdAt", defaultDays: 30, fullHistoryDays: 365 }),
860
+ notifications: Object.freeze({ field: "createdAt", defaultDays: 180, fullHistoryDays: 1095 }),
861
+ // `occurredAt`, not `createdAt`: `PluginUsageEvent` names its
862
+ // `@CreateDateColumn` `occurredAt` and has no `createdAt` at all. The trim
863
+ // resolver fails OPEN on an unknown column, so the wrong name here did not
864
+ // error — it silently exported every plugin usage event for the lifetime
865
+ // of the workspace, from the archive's single largest history table, while
866
+ // the docs page published a 180-day window.
867
+ pluginUsageEvents: Object.freeze({ field: "occurredAt", defaultDays: 180, fullHistoryDays: 1095 }),
868
+ deliveryLogs: Object.freeze({ field: "createdAt", defaultDays: 30, fullHistoryDays: 365 }),
869
+ // `firedAt`, for the same reason: `InboundTriggerFire`'s creation
870
+ // timestamp is `firedAt` and it has no `createdAt`, so the declared
871
+ // 90-day window was never applied to `data/schedules/trigger-fires.jsonl`
872
+ // and the `schedules` domain reported `complete` where the docs promise a
873
+ // trim.
874
+ triggerFires: Object.freeze({ field: "firedAt", defaultDays: 90, fullHistoryDays: 1095 }),
875
+ retrievalTrail: Object.freeze({ field: "createdAt", defaultDays: 30, fullHistoryDays: 365 }),
876
+ fleetJobs: Object.freeze({ field: "createdAt", defaultDays: 30, fullHistoryDays: 365 })
877
+ });
878
+ var BACKUP_DOMAINS = Object.freeze([
879
+ Object.freeze({
880
+ key: "account",
881
+ restorability: "restorable",
882
+ collectorId: "account",
883
+ dataDir: "account"
884
+ }),
885
+ Object.freeze({
886
+ key: "organizations",
887
+ restorability: "restorable",
888
+ collectorId: "organizations",
889
+ dataDir: "organizations"
890
+ }),
891
+ Object.freeze({
892
+ key: "agents",
893
+ restorability: "restorable",
894
+ collectorId: "agents",
895
+ dataDir: "agents"
896
+ }),
897
+ Object.freeze({
898
+ key: "missions",
899
+ restorability: "restorable",
900
+ collectorId: "missions",
901
+ dataDir: "missions"
902
+ }),
903
+ Object.freeze({
904
+ key: "tasks",
905
+ restorability: "restorable",
906
+ collectorId: "tasks",
907
+ dataDir: "tasks"
908
+ }),
909
+ Object.freeze({
910
+ key: "works",
911
+ restorability: "restorable",
912
+ collectorId: "works",
913
+ dataDir: "works"
914
+ }),
915
+ Object.freeze({
916
+ key: "knowledge",
917
+ restorability: "restorable",
918
+ collectorId: "knowledge",
919
+ dataDir: "knowledge"
920
+ }),
921
+ Object.freeze({
922
+ key: "schedules",
923
+ restorability: "restorable",
924
+ collectorId: "schedules",
925
+ dataDir: "schedules"
926
+ }),
927
+ Object.freeze({
928
+ key: "runs",
929
+ restorability: "record-only",
930
+ collectorId: "runs",
931
+ dataDir: "runs"
932
+ }),
933
+ Object.freeze({
934
+ key: "decisions",
935
+ restorability: "record-only",
936
+ collectorId: "decisions",
937
+ dataDir: "decisions"
938
+ }),
939
+ Object.freeze({
940
+ key: "communication",
941
+ restorability: "partly-restorable",
942
+ collectorId: "communication",
943
+ dataDir: "communication"
944
+ }),
945
+ Object.freeze({
946
+ key: "connections",
947
+ restorability: "restorable",
948
+ collectorId: "connections",
949
+ dataDir: "connections"
950
+ }),
951
+ Object.freeze({
952
+ key: "fleet",
953
+ restorability: "partly-restorable",
954
+ collectorId: "fleet",
955
+ dataDir: "fleet"
956
+ }),
957
+ Object.freeze({
958
+ key: "billing",
959
+ restorability: "record-only",
960
+ collectorId: "billing",
961
+ dataDir: "billing"
962
+ }),
963
+ Object.freeze({
964
+ key: "activity",
965
+ restorability: "record-only",
966
+ collectorId: "activity",
967
+ dataDir: "activity",
968
+ trim: BACKUP_TRIM_POLICIES.activity
969
+ })
970
+ ]);
971
+ var BACKUP_DOMAIN_KEYS = Object.freeze(BACKUP_DOMAINS.map((domain) => domain.key));
972
+ var BACKUP_DOMAIN_COUNT = BACKUP_DOMAINS.length;
973
+ var BACKUP_EXCLUSIONS = Object.freeze([
974
+ Object.freeze({
975
+ code: "auth_tokens",
976
+ summary: "Password hashes, password-reset tokens, magic-link tokens and email-verification tokens."
977
+ }),
978
+ Object.freeze({
979
+ code: "sessions",
980
+ summary: "Sessions, refresh tokens, and third-party auth provider access and refresh tokens."
981
+ }),
982
+ Object.freeze({
983
+ code: "api_key_material",
984
+ summary: "API key material. Keys appear as name, prefix, created date and active flag only."
985
+ }),
986
+ Object.freeze({
987
+ code: "stored_credentials",
988
+ summary: "Every stored credential, in plaintext or ciphertext \u2014 plugin secrets, connection headers, deployment secrets, webhook and trigger signing secrets, and encrypted runtime credentials. Only the names of the fields that were set are exported."
989
+ }),
990
+ Object.freeze({
991
+ code: "node_enrolment_secrets",
992
+ summary: "Node enrolment and heartbeat secrets."
993
+ }),
994
+ Object.freeze({
995
+ code: "payment_identifiers",
996
+ summary: "Payment-provider customer, subscription, payment-method and meter identifiers."
997
+ }),
998
+ Object.freeze({
999
+ code: "platform_admin_flag",
1000
+ summary: "The platform-administrator flag."
1001
+ }),
1002
+ Object.freeze({
1003
+ code: "vector_embeddings",
1004
+ summary: "Vector embeddings and their coordinates, because they are derived and regenerate on demand."
1005
+ }),
1006
+ Object.freeze({
1007
+ code: "internal_caches",
1008
+ summary: "Internal caches and the delivery outbox."
1009
+ })
1010
+ ]);
1011
+ var BACKUP_DEFAULT_LIMITS = Object.freeze({
1012
+ /** Ready archives kept for this long before their bytes are deleted. */
1013
+ retentionDays: 14,
1014
+ /** The record outlives its bytes by this long, so history stays legible. */
1015
+ recordRetentionDays: 90,
1016
+ /** Ready outcomes allowed per rolling 24 hours. Failures do not count. */
1017
+ dailyAllowance: 3,
1018
+ /** Rows the history list shows before "Show all". */
1019
+ historyPageSize: 20,
1020
+ /** Hard ceiling on a single history page. */
1021
+ historyPageLimit: 50,
1022
+ /** Bytes of one uploaded file that may be carried (200 MiB). */
1023
+ maxFileBytes: 200 * 1024 * 1024,
1024
+ /** Bytes of uploaded files in total (2 GiB). */
1025
+ maxAttachmentBytes: 2 * 1024 * 1024 * 1024,
1026
+ /** Bytes the finished archive may not exceed with a streaming backend (5 GiB). */
1027
+ maxArchiveBytes: 5 * 1024 * 1024 * 1024,
1028
+ /**
1029
+ * The ceiling when the active storage backend cannot accept a stream and
1030
+ * the archive has to be handed over as one buffer (512 MiB). A backend
1031
+ * without streaming is a smaller limit, never a wrong result.
1032
+ */
1033
+ maxBufferedArchiveBytes: 512 * 1024 * 1024,
1034
+ /** A manifest larger than this is refused by the check without being read (8 MiB). */
1035
+ maxManifestBytes: 8 * 1024 * 1024,
1036
+ /** Minutes a download link stays valid before the client re-mints it. */
1037
+ downloadLinkTtlMinutes: 15,
1038
+ /** Minutes without a heartbeat before a running backup is called stalled. */
1039
+ stallMinutes: 10,
1040
+ /** Minutes a queued backup may wait before it is called stalled. */
1041
+ queuedStallMinutes: 15,
1042
+ /** Minutes a backup may run before it is stopped. */
1043
+ timeoutMinutes: 60,
1044
+ /** Retries a single domain gets before it is marked failed and the archive finishes without it. */
1045
+ domainRetries: 2,
1046
+ /** Retries a storage write gets before the backup fails as `storage_unavailable`. */
1047
+ storageRetries: 3
1048
+ });
1049
+ var HOUR_MS = 60 * 60 * 1e3;
1050
+ var MINUTE_MS = 60 * 1e3;
1051
+ var ACTION_CATEGORIES = Object.freeze([
1052
+ "read.internal",
1053
+ "read.external",
1054
+ "write.internal",
1055
+ "write.destructive",
1056
+ "message.internal",
1057
+ "message.external",
1058
+ "publish.external",
1059
+ "spend.metered",
1060
+ "spend.commitment",
1061
+ "access.grant",
1062
+ "machine.run",
1063
+ "machine.admin",
1064
+ "agent.fanout"
1065
+ ]);
1066
+ var LADDERED_CATEGORIES = Object.freeze(
1067
+ ACTION_CATEGORIES.filter((c) => c !== "read.internal")
1068
+ );
1069
+ var DRAFTABLE_CATEGORIES = Object.freeze([
1070
+ "write.internal",
1071
+ "write.destructive",
1072
+ "message.external",
1073
+ "publish.external",
1074
+ "machine.run",
1075
+ "machine.admin"
1076
+ ]);
1077
+ var ACTION_CATEGORY_CEILING = Object.freeze({
1078
+ "read.external": "auto",
1079
+ "write.internal": "auto",
1080
+ "write.destructive": "ask",
1081
+ "message.internal": "auto",
1082
+ "message.external": "ask",
1083
+ "publish.external": "ask",
1084
+ "spend.metered": "auto",
1085
+ "spend.commitment": "off",
1086
+ "access.grant": "ask",
1087
+ "machine.run": "auto",
1088
+ "machine.admin": "ask",
1089
+ "agent.fanout": "auto"
1090
+ });
1091
+ var ACTION_CATEGORY_DEFAULT = Object.freeze({
1092
+ "read.external": "auto",
1093
+ "write.internal": "auto",
1094
+ "write.destructive": "ask",
1095
+ "message.internal": "auto",
1096
+ "message.external": "draft",
1097
+ "publish.external": "ask",
1098
+ "spend.metered": "auto",
1099
+ "spend.commitment": "off",
1100
+ "access.grant": "ask",
1101
+ "machine.run": "ask",
1102
+ "machine.admin": "ask",
1103
+ "agent.fanout": "ask"
1104
+ });
1105
+ var ACTION_CATEGORY_RESTRICTIVENESS = Object.freeze([
1106
+ "spend.commitment",
1107
+ "access.grant",
1108
+ "machine.admin",
1109
+ "publish.external",
1110
+ "message.external",
1111
+ "write.destructive",
1112
+ "machine.run",
1113
+ "agent.fanout",
1114
+ "spend.metered",
1115
+ "write.internal",
1116
+ "message.internal",
1117
+ "read.external",
1118
+ "read.internal"
1119
+ ]);
1120
+ var ACTION_CATEGORY_I18N_KEY = Object.freeze({
1121
+ "read.internal": "readInternal",
1122
+ "read.external": "readExternal",
1123
+ "write.internal": "writeInternal",
1124
+ "write.destructive": "writeDestructive",
1125
+ "message.internal": "messageInternal",
1126
+ "message.external": "messageExternal",
1127
+ "publish.external": "publishExternal",
1128
+ "spend.metered": "spendMetered",
1129
+ "spend.commitment": "spendCommitment",
1130
+ "access.grant": "accessGrant",
1131
+ "machine.run": "machineRun",
1132
+ "machine.admin": "machineAdmin",
1133
+ "agent.fanout": "agentFanout"
1134
+ });
1135
+ var TRUST_RUNG_ORDER = Object.freeze([
1136
+ "off",
1137
+ "draft",
1138
+ "ask",
1139
+ "auto"
1140
+ ]);
1141
+ var SAFETY_RAIL_ORDER = Object.freeze([
1142
+ "platform-stop",
1143
+ "workspace-pause",
1144
+ "scope-pause",
1145
+ "grants",
1146
+ "ladder",
1147
+ "caps",
1148
+ "rules"
1149
+ ]);
1150
+ var SAFETY_REASON_CODES = Object.freeze([
1151
+ "platform-stopped",
1152
+ "workspace-paused",
1153
+ "scope-paused",
1154
+ "rung-off",
1155
+ "rung-held",
1156
+ "ceiling-refused",
1157
+ "cap-reached",
1158
+ "grant-denied",
1159
+ "rule-blocked",
1160
+ "policy-refused",
1161
+ "unclassified-action",
1162
+ "instruction-widening-attempt",
1163
+ "non-human-actor",
1164
+ "safe-mode"
1165
+ ]);
1166
+ var SAFETY_ALLOW = Object.freeze({
1167
+ decision: "allow",
1168
+ railId: null,
1169
+ category: null,
1170
+ rung: null,
1171
+ reasonCode: null,
1172
+ summary: null
1173
+ });
1174
+ var AUTONOMY_GRANT_SCOPE_PRECEDENCE = Object.freeze([
1175
+ "workspace",
1176
+ "agent"
1177
+ ]);
1178
+ var RAIL_REFUSAL_SUBJECT_TYPES = Object.freeze([
1179
+ "run",
1180
+ "agent",
1181
+ "mission",
1182
+ "task",
1183
+ "schedule",
1184
+ "trigger"
1185
+ ]);
1186
+ var WORKSPACE_RUNNING = Object.freeze({
1187
+ paused: false,
1188
+ unverified: false,
1189
+ reason: null,
1190
+ pausedByUserId: null,
1191
+ pausedAt: null,
1192
+ refusedStarts: 0,
1193
+ cleanlyStopped: 0
1194
+ });
1195
+ var WORKSPACE_PAUSED_UNVERIFIED = Object.freeze({
1196
+ paused: true,
1197
+ unverified: true,
1198
+ reason: null,
1199
+ pausedByUserId: null,
1200
+ pausedAt: null,
1201
+ refusedStarts: 0,
1202
+ cleanlyStopped: 0
1203
+ });
1204
+ var READINESS_BLOCKERS = Object.freeze([
1205
+ "at-ceiling",
1206
+ "too-few-decisions",
1207
+ "approval-rate",
1208
+ "withdrawn",
1209
+ "other-refusals"
1210
+ ]);
1211
+
1212
+ // ../../plugin/dist/index.js
1213
+ var DEFAULT_DIFF_MAX_BYTES = 256 * 1024;
1214
+ var HARD_DIFF_MAX_BYTES = 1024 * 1024;
1215
+ var PLUGIN_CAPABILITIES = {
1216
+ AI_PROVIDER: "ai-provider",
1217
+ SEARCH: "search",
1218
+ SCREENSHOT: "screenshot",
1219
+ CONTENT_EXTRACTOR: "content-extractor",
1220
+ DATA_SOURCE: "data-source",
1221
+ PIPELINE: "pipeline",
1222
+ PIPELINE_MODIFIER: "pipeline-modifier",
1223
+ CODE_EDIT: "code-edit",
1224
+ FORM_SCHEMA_PROVIDER: "form-schema-provider",
1225
+ DEPLOYMENT: "deployment",
1226
+ GIT_PROVIDER: "git-provider",
1227
+ OAUTH: "oauth",
1228
+ DEVICE_AUTH: "device-auth",
1229
+ PROMPT_PROVIDER: "prompt-provider",
1230
+ // EW-637 — pluggable object storage. `put-object` + `get-object` are the
1231
+ // floor; `presigned-put` is opt-in for backends that can hand the
1232
+ // browser a direct-upload URL (S3, MinIO).
1233
+ STORAGE: "storage",
1234
+ PUT_OBJECT: "put-object",
1235
+ GET_OBJECT: "get-object",
1236
+ PRESIGNED_PUT: "presigned-put",
1237
+ // Agents/Skills/Tasks PR #1017 — Phase 8 (ADR-012). Plugin
1238
+ // category for Skill catalog providers. "Ever Works Skills" is
1239
+ // the first-party default; community plugins implement the same
1240
+ // `ISkillsProviderPlugin` contract to surface other catalogs.
1241
+ SKILLS_PROVIDER: "skills-provider",
1242
+ // Agents/Skills/Tasks PR #1017 — Phase 11 (ADR-013). Plugin
1243
+ // category for external task trackers. "Ever Works Task Tracker"
1244
+ // is the first-party default. Community plugins (Linear / Jira /
1245
+ // GitHub Issues) implement the same contract.
1246
+ TASK_TRACKER: "task-tracker",
1247
+ // Notifications v2 (EW-650) — Email Providers. Plugins MAY declare
1248
+ // `EMAIL_OUTBOUND`, `EMAIL_INBOUND`, or both. See
1249
+ // `capabilities/email-provider.interface.ts` for the contract.
1250
+ EMAIL_OUTBOUND: "email-outbound",
1251
+ EMAIL_INBOUND: "email-inbound",
1252
+ // Notifications v2 (sibling of EW-650) — Notification Channels.
1253
+ // Plugins declare `NOTIFICATION_CHANNEL` (umbrella) plus the
1254
+ // channel-specific constant for plugin discovery + UI grouping.
1255
+ // See `capabilities/notification-channel.interface.ts`.
1256
+ NOTIFICATION_CHANNEL: "notification-channel",
1257
+ NOTIFICATION_CHANNEL_DISCORD: "notification-channel-discord",
1258
+ NOTIFICATION_CHANNEL_SLACK: "notification-channel-slack",
1259
+ NOTIFICATION_CHANNEL_TELEGRAM: "notification-channel-telegram",
1260
+ NOTIFICATION_CHANNEL_WHATSAPP: "notification-channel-whatsapp",
1261
+ NOTIFICATION_CHANNEL_NOVU: "notification-channel-novu",
1262
+ // Connectors ("Connector fabric") — first-party BIDIRECTIONAL
1263
+ // communication-channel plugins. Each connector declares `CONNECTOR`
1264
+ // (umbrella, for discovery/grouping) plus its `CONNECTOR_<provider>`
1265
+ // constant. Superset of the outbound-only notification channels; see
1266
+ // `capabilities/connector.interface.ts`.
1267
+ CONNECTOR: "connector",
1268
+ CONNECTOR_SLACK: "connector-slack",
1269
+ CONNECTOR_DISCORD: "connector-discord",
1270
+ CONNECTOR_WHATSAPP: "connector-whatsapp",
1271
+ CONNECTOR_LINEAR: "connector-linear",
1272
+ CONNECTOR_NOTION: "connector-notion",
1273
+ CONNECTOR_MICROSOFT_365: "connector-microsoft-365",
1274
+ // CRM / enrichment connectors — first-party native connectors over
1275
+ // the vendors' own Node SDKs. Outbound writes CRM records/notes,
1276
+ // the event-source leg streams record changes into the ingest spine.
1277
+ CONNECTOR_HUBSPOT: "connector-hubspot",
1278
+ CONNECTOR_PIPEDRIVE: "connector-pipedrive",
1279
+ // Social connectors — public-timeline surfaces. Outbound publishes a
1280
+ // post; the event-source leg streams mentions/replies + the account's
1281
+ // own timeline into the ingest spine.
1282
+ CONNECTOR_BLUESKY: "connector-bluesky",
1283
+ CONNECTOR_MASTODON: "connector-mastodon",
1284
+ // Pluggable persistent memory for AI coding / generation agents.
1285
+ // First-party implementation: `@ever-works/agentmemory-plugin`
1286
+ // (talks to the `agentmemory` standalone Node server on :3111 —
1287
+ // runs locally OR hosted via a configurable `baseUrl` + bearer
1288
+ // token). Community plugins (mem0, zep, langmem) implement the
1289
+ // same `IAgentMemoryPlugin` contract.
1290
+ AGENT_MEMORY: "agent-memory",
1291
+ // Goals feature PR-7 — read-only metrics collectors. First-party
1292
+ // providers: `custom-http` (GET-only, SSRF-guarded) and `stripe`
1293
+ // (official SDK; balance + income windows). See
1294
+ // `capabilities/metrics-provider.interface.ts` for the contract.
1295
+ METRICS_PROVIDER: "metrics-provider",
1296
+ // Streaming-terminal session hosts (Wave 1 M5). First-party:
1297
+ // pty-local (node-pty in the executing job-runtime worker, with a
1298
+ // child_process pipe floor). Future: pty-ssh (user's own box),
1299
+ // k8s-exec. See capabilities/terminal-stream.interface.ts.
1300
+ TERMINAL_STREAM: "terminal-stream",
1301
+ // Isolated git working contexts for agent Tasks (Wave 2).
1302
+ WORKSPACE: "workspace",
1303
+ // Headless browser drivers (audit item G22). navigate / extract /
1304
+ // screenshot / act, headless by default, behind a default-deny
1305
+ // navigation allowlist re-checked on every redirect hop. First-party:
1306
+ // `browser-automation` (Playwright). See
1307
+ // capabilities/browser-automation.interface.ts.
1308
+ BROWSER_AUTOMATION: "browser-automation",
1309
+ // Event-ingest spine (Wave 6) — plugins that pull/push normalized
1310
+ // external events into the platform ingest pipeline. See
1311
+ // capabilities/event-source.interface.ts.
1312
+ EVENT_SOURCE: "event-source",
1313
+ // Capability & playbook catalogue (AW-21) — plugins that supply Playbook
1314
+ // catalogue entries (packaged outcomes: trigger, steps, required
1315
+ // capabilities, artefacts, escalation points). Consumed only through
1316
+ // PlaybookCatalogFacadeService. See
1317
+ // capabilities/playbook-provider.interface.ts.
1318
+ PLAYBOOK_PROVIDER: "playbook-provider",
1319
+ // AW-15 — optional access-level declaration ("Read only" / "Read and
1320
+ // write") mapped onto the tool-grant lattice. Declared alongside a
1321
+ // provider's main capability; see
1322
+ // capabilities/connection-scopes.interface.ts.
1323
+ CONNECTION_SCOPES: "connection-scopes",
1324
+ // App Works (APW-07 T3) — App dependency providers: the `k8s` plugin serves
1325
+ // the three in-cluster ids, `app-dependencies-external` the external ones,
1326
+ // and P2's `apps-tier-dependencies` the managed ones. Consumed only through
1327
+ // `AppDependencyFacadeService`; see
1328
+ // capabilities/app-dependency.interface.ts.
1329
+ APP_DEPENDENCY: "app-dependency",
1330
+ // App Works (APW-05 T2) — producing a container image from a commit. The
1331
+ // `github-actions-build` plugin declares it (`IBuildPlugin`, `buildKind
1332
+ // 'github-actions'`), and P3's `apps-builder` declares the same capability
1333
+ // with the other `buildKind`. Consumed only through `BuildFacadeService`
1334
+ // (APW-05 T16); see `capabilities/build.interface.ts`. Resolution R-13 keeps
1335
+ // `build` a CAPABILITY while the strategy (`dockerfile` / `image` / `auto` /
1336
+ // `none`) stays the App spec's own choice.
1337
+ BUILD: "build",
1338
+ // App Works (APW-12 T4) — the Ever ID relying-party capability. The
1339
+ // `oidc-identity` plugin declares it (APW-12 T5/T6), and it is consumed only
1340
+ // through `IdentityProviderFacadeService` (APW-12 plan §4.4); see
1341
+ // `capabilities/identity-provider.interface.ts`. Appended after `build`, and
1342
+ // landed in the same change as the `identity` category in
1343
+ // `plugin-manifest.types.ts` — a plugin contract with no category to be
1344
+ // discovered under is a manifest the loader rejects.
1345
+ IDENTITY_PROVIDER: "identity-provider",
1346
+ // App Works (APW-10 T2) — the managed hosting tier's zone capability. The
1347
+ // `ever-works-apps` plugin declares it together with `deployment`
1348
+ // (plan §5.2:602) and is consumed only through `AppsTierFacadeService`
1349
+ // (APW-10 plan §5.3:655); see `capabilities/apps-tier.interface.ts`.
1350
+ // Appended after `identity-provider`, and appended **last** on purpose:
1351
+ // every existing constant keeps its value and its position, because the
1352
+ // capability list is an append-only surface — a member that moves silently
1353
+ // changes what a persisted manifest means. Deliberately NOT paired with a
1354
+ // new category: the tier's plugin is a `deployment` plugin, so
1355
+ // `PLUGIN_CATEGORIES` is untouched and every exhaustive map over it in
1356
+ // `apps/web` stays total.
1357
+ APPS_TIER: "apps-tier"
1358
+ };
1359
+ var ALL_PLUGIN_CAPABILITIES = Object.values(PLUGIN_CAPABILITIES);
1360
+ var IdentityTokenRejectedError = class extends Error {
1361
+ constructor(code) {
1362
+ super(code);
1363
+ this.code = code;
1364
+ this.name = "IdentityTokenRejectedError";
1365
+ }
1366
+ };
1367
+ var ITEM_SCHEMA_PROMPT_TEXT = `
1368
+ Each item must be a JSON file with the following structure:
1369
+
1370
+ {
1371
+ "name": "string (required) - The item's canonical display name",
1372
+ "description": "string (required) - A concise, informative summary of the item and its relevance to the work topic (2-4 sentences). Generate from page content if not directly available.",
1373
+ "source_url": "string (required) - The most direct, canonical URL for the item itself (homepage or official page). Must be real and working. Never use blog posts or articles *about* the item.",
1374
+ "category": "string (required) - ONE primary category based on the item's core function (e.g., 'Monitoring', 'CI/CD', 'Data Visualization')",
1375
+ "tags": ["string"] - 1-3 specific, descriptive tags (e.g., "open-source", "real-time", "cloud-native"),
1376
+ "featured": boolean - Whether this is a notable/featured item (optional, default false),
1377
+ "slug": "string - URL-friendly identifier (optional, auto-generated from name if omitted)",
1378
+ "brand": "string - At most one brand/company name per item (optional)",
1379
+ "brand_logo_url": "string - Canonical logo URL from official domain, prefer SVG/PNG (optional)",
1380
+ "images": ["string"] - Multiple high-quality image URLs (screenshots, product imagery) from official sources (optional),
1381
+ "markdown": "string - Detailed product/service information in markdown: features, pricing, use cases (see Markdown Rules below). Do not repeat category, tags, or other metadata fields."
1382
+ }
1383
+
1384
+ Required fields: name, description, source_url, category
1385
+
1386
+ ### Example
1387
+
1388
+ \`\`\`json
1389
+ {
1390
+ "name": "Prometheus",
1391
+ "description": "Open-source monitoring and alerting toolkit designed for reliability and scalability, widely adopted in cloud-native environments.",
1392
+ "source_url": "https://prometheus.io",
1393
+ "category": "Monitoring",
1394
+ "tags": ["open-source", "metrics", "cloud-native"],
1395
+ "featured": false,
1396
+ "brand": "Cloud Native Computing Foundation",
1397
+ "brand_logo_url": "https://www.cncf.io/wp-content/uploads/2022/07/cncf-color-bg.svg",
1398
+ "images": ["https://prometheus.io/assets/architecture.png"],
1399
+ "markdown": "## Overview\\n\\nPrometheus is an open-source systems monitoring and alerting toolkit originally built at SoundCloud.\\n\\n## Features\\n\\n- Multi-dimensional data model with time series data identified by metric name and key/value pairs\\n- PromQL, a flexible query language\\n- No reliance on distributed storage; single server nodes are autonomous\\n- Time series collection via a pull model over HTTP\\n- Targets discovered via service discovery or static configuration\\n\\n## Pricing\\n\\nFree and open-source under the Apache 2.0 license."
1400
+ }
1401
+ \`\`\`
1402
+ `.trim();
1403
+
1404
+ // src/oidc-identity.plugin.ts
1405
+ import {
1406
+ ClientSecretBasic,
1407
+ Configuration,
1408
+ allowInsecureRequests,
1409
+ buildAuthorizationUrl,
1410
+ calculatePKCECodeChallenge
1411
+ } from "openid-client";
1412
+
1413
+ // src/discovery.ts
1414
+ var OIDC_OUTBOUND_TIMEOUT_MS = 5e3;
1415
+ var OIDC_OUTBOUND_RETRY_DELAY_MS = 1e3;
1416
+ var OIDC_OUTBOUND_MAX_ATTEMPTS = 2;
1417
+ var OIDC_DISCOVERY_CACHE_SECONDS = 3600;
1418
+ var OIDC_JWKS_CACHE_SECONDS = 600;
1419
+ var OIDC_JWKS_UNKNOWN_KID_COOLDOWN_SECONDS = 30;
1420
+ var OIDC_JWKS_MAX_STALE_SECONDS = 21600;
1421
+ var OIDC_IDENTITY_SIGNING_ALGS = ["RS256", "ES256", "EdDSA"];
1422
+ var OidcProviderUnavailableError = class extends Error {
1423
+ constructor(reason) {
1424
+ super("providerUnavailable");
1425
+ this.reason = reason;
1426
+ this.code = "providerUnavailable";
1427
+ this.name = "OidcProviderUnavailableError";
1428
+ }
1429
+ };
1430
+ var oidcDefaultFetch = (url, init) => fetch(url, init);
1431
+ async function fetchJsonOnce(request, options = {}) {
1432
+ const now = options.now ?? Date.now;
1433
+ const startedAt = now();
1434
+ const attempts = { count: 0 };
1435
+ const work = (async () => {
1436
+ attempts.count += 1;
1437
+ return {
1438
+ ...await readJsonOnce(
1439
+ request,
1440
+ options.fetchImpl ?? oidcDefaultFetch,
1441
+ options.timeoutMs ?? OIDC_OUTBOUND_TIMEOUT_MS
1442
+ ),
1443
+ attempts: attempts.count
1444
+ };
1445
+ })();
1446
+ if (options.deadlineAt === void 0) return work;
1447
+ const remaining = Math.max(0, options.deadlineAt - startedAt);
1448
+ return settleWithin(work, remaining, () => ({ ok: false, failure: "timeout", attempts: attempts.count }));
1449
+ }
1450
+ async function fetchJsonWithOneRetry(request, options = {}) {
1451
+ const now = options.now ?? Date.now;
1452
+ const startedAt = now();
1453
+ const attempts = { count: 0 };
1454
+ const work = attemptJsonFetch(request, options, attempts);
1455
+ if (options.deadlineAt === void 0) return work;
1456
+ const remaining = Math.max(0, options.deadlineAt - startedAt);
1457
+ return settleWithin(work, remaining, () => ({ ok: false, failure: "timeout", attempts: attempts.count }));
1458
+ }
1459
+ async function attemptJsonFetch(request, options, attempts) {
1460
+ const now = options.now ?? Date.now;
1461
+ const timeoutMs = options.timeoutMs ?? OIDC_OUTBOUND_TIMEOUT_MS;
1462
+ const retryDelayMs = options.retryDelayMs ?? OIDC_OUTBOUND_RETRY_DELAY_MS;
1463
+ const deadlineAt = options.deadlineAt;
1464
+ let last = { ok: false, failure: "timeout", attempts: 0 };
1465
+ try {
1466
+ for (let attempt = 1; attempt <= OIDC_OUTBOUND_MAX_ATTEMPTS; attempt += 1) {
1467
+ if (attempt > 1) {
1468
+ if (deadlineAt !== void 0 && now() + retryDelayMs >= deadlineAt) break;
1469
+ await delayFor(retryDelayMs);
1470
+ }
1471
+ if (deadlineAt !== void 0 && now() >= deadlineAt) break;
1472
+ const perAttemptMs = deadlineAt === void 0 ? timeoutMs : Math.max(1, Math.min(timeoutMs, deadlineAt - now()));
1473
+ attempts.count = attempt;
1474
+ last = {
1475
+ ...await readJsonOnce(request, options.fetchImpl ?? oidcDefaultFetch, perAttemptMs),
1476
+ attempts: attempt
1477
+ };
1478
+ if (last.ok) return last;
1479
+ }
1480
+ } catch {
1481
+ }
1482
+ return { ...last, attempts: attempts.count };
1483
+ }
1484
+ async function readJsonOnce(request, fetchImpl, timeoutMs) {
1485
+ const controller = new AbortController();
1486
+ const timer = scheduleTimeout(() => controller.abort(), timeoutMs);
1487
+ try {
1488
+ const response = await fetchImpl(request.url, {
1489
+ signal: controller.signal,
1490
+ method: request.method ?? "GET",
1491
+ ...request.body === void 0 ? {} : { body: request.body },
1492
+ headers: {
1493
+ accept: "application/json",
1494
+ ...request.contentType === void 0 ? {} : { "content-type": request.contentType },
1495
+ ...request.headers
1496
+ }
1497
+ });
1498
+ if (!response || typeof response.status !== "number" || response.ok !== true) {
1499
+ return { ok: false, failure: "httpStatus", status: response?.status };
1500
+ }
1501
+ try {
1502
+ return { ok: true, status: response.status, body: await response.json() };
1503
+ } catch {
1504
+ return { ok: false, failure: "invalidBody", status: response.status };
1505
+ }
1506
+ } catch {
1507
+ return { ok: false, failure: controller.signal.aborted ? "timeout" : "network" };
1508
+ } finally {
1509
+ clearTimeout(timer);
1510
+ }
1511
+ }
1512
+ function settleWithin(work, ms, fallback) {
1513
+ return new Promise((resolve) => {
1514
+ const timer = scheduleTimeout(() => resolve(fallback()), ms);
1515
+ const done = (value) => {
1516
+ clearTimeout(timer);
1517
+ resolve(value);
1518
+ };
1519
+ work.then(done, () => done(fallback()));
1520
+ });
1521
+ }
1522
+ function scheduleTimeout(callback, ms) {
1523
+ const timer = setTimeout(callback, ms);
1524
+ const unref = timer.unref;
1525
+ if (typeof unref === "function") unref.call(timer);
1526
+ return timer;
1527
+ }
1528
+ function delayFor(ms) {
1529
+ if (ms <= 0) return Promise.resolve();
1530
+ return new Promise((resolve) => {
1531
+ scheduleTimeout(resolve, ms);
1532
+ });
1533
+ }
1534
+ function buildDiscoveryUrl(issuerUrl) {
1535
+ const url = new URL(issuerUrl);
1536
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/.well-known/openid-configuration`;
1537
+ url.search = "";
1538
+ url.hash = "";
1539
+ return url.toString();
1540
+ }
1541
+ var OidcDiscoveryReader = class {
1542
+ constructor(options) {
1543
+ this.options = options;
1544
+ this.document = null;
1545
+ this.fetchedAtMs = null;
1546
+ }
1547
+ /** The configured issuer this reader answers for (FR-7: one issuer set per installation). */
1548
+ get issuerUrl() {
1549
+ return this.options.issuerUrl;
1550
+ }
1551
+ /**
1552
+ * When the last successful read happened, in epoch ms, or `null`.
1553
+ *
1554
+ * This is the in-process source of plan §4.2's `availability.discoveryRefreshedAt`
1555
+ * and of plan §9.1's health view; T32 persists it.
1556
+ */
1557
+ get lastRefreshedAt() {
1558
+ return this.fetchedAtMs;
1559
+ }
1560
+ /** The cached document while FR-14's 3,600 seconds have not elapsed, else `null`. */
1561
+ cachedDocument() {
1562
+ if (this.document === null || this.fetchedAtMs === null) return null;
1563
+ const cacheMs = (this.options.cacheSeconds ?? OIDC_DISCOVERY_CACHE_SECONDS) * 1e3;
1564
+ return this.now() - this.fetchedAtMs < cacheMs ? this.document : null;
1565
+ }
1566
+ /**
1567
+ * Read the document from the provider now (FR-3's read, FR-14's 3,600-second cache
1568
+ * entry point).
1569
+ *
1570
+ * A successful read replaces the cache even when the issuer drifts: the drift is
1571
+ * what an administrator needs to see, and {@link get} refuses it either way.
1572
+ */
1573
+ async read(options = {}) {
1574
+ let url;
1575
+ try {
1576
+ url = buildDiscoveryUrl(this.options.issuerUrl);
1577
+ } catch {
1578
+ return { ok: false, failure: "invalidIssuer", attempts: 0 };
1579
+ }
1580
+ const result = await fetchJsonWithOneRetry(
1581
+ { url },
1582
+ {
1583
+ fetchImpl: this.options.fetchImpl,
1584
+ now: this.options.now,
1585
+ timeoutMs: this.options.timeoutMs,
1586
+ retryDelayMs: this.options.retryDelayMs,
1587
+ deadlineAt: options.deadlineAt
1588
+ }
1589
+ );
1590
+ if (!result.ok) {
1591
+ return {
1592
+ ok: false,
1593
+ failure: result.failure ?? "network",
1594
+ attempts: result.attempts,
1595
+ status: result.status
1596
+ };
1597
+ }
1598
+ const document = asDiscoveryDocument(result.body);
1599
+ if (document === null) {
1600
+ return { ok: false, failure: "invalidDocument", attempts: result.attempts, status: result.status };
1601
+ }
1602
+ const fetchedAt = this.now();
1603
+ this.document = document;
1604
+ this.fetchedAtMs = fetchedAt;
1605
+ return {
1606
+ ok: true,
1607
+ document,
1608
+ fetchedAt,
1609
+ issuerMatches: document.issuer === this.options.issuerUrl,
1610
+ attempts: result.attempts
1611
+ };
1612
+ }
1613
+ /**
1614
+ * The document, from the cache while it is fresh and from the provider once it is
1615
+ * not (FR-14). Throws {@link OidcProviderUnavailableError} rather than returning
1616
+ * `null`: every caller of this method is on a path that must not continue without
1617
+ * a document.
1618
+ */
1619
+ async get() {
1620
+ const cached = this.cachedDocument();
1621
+ if (cached !== null) {
1622
+ this.assertIssuerMatches(cached);
1623
+ return cached;
1624
+ }
1625
+ const read = await this.read();
1626
+ if (!read.ok) throw new OidcProviderUnavailableError("discoveryFailed");
1627
+ this.assertIssuerMatches(read.document);
1628
+ return read.document;
1629
+ }
1630
+ /** FR-14: a different issuer turns sign-in off until an administrator re-tests. */
1631
+ assertIssuerMatches(document) {
1632
+ if (document.issuer !== this.options.issuerUrl) throw new OidcProviderUnavailableError("issuerDrift");
1633
+ }
1634
+ now() {
1635
+ return (this.options.now ?? Date.now)();
1636
+ }
1637
+ };
1638
+ function asDiscoveryDocument(value) {
1639
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
1640
+ const candidate = value;
1641
+ if (typeof candidate.issuer !== "string" || candidate.issuer.length === 0) return null;
1642
+ return value;
1643
+ }
1644
+
1645
+ // src/jwks-cache.ts
1646
+ import { compactVerify, createLocalJWKSet, decodeProtectedHeader, errors } from "jose";
1647
+ var OidcJwksVerificationError = class extends Error {
1648
+ constructor(code) {
1649
+ super(code);
1650
+ this.code = code;
1651
+ this.name = "OidcJwksVerificationError";
1652
+ }
1653
+ };
1654
+ var OidcJwksCache = class {
1655
+ constructor(options) {
1656
+ this.options = options;
1657
+ this.jwks = null;
1658
+ this.resolveKey = null;
1659
+ this.refreshedAtMs = null;
1660
+ this.lastAttemptAtMs = null;
1661
+ this.lastUnknownKidRefreshAtMs = null;
1662
+ this.lastFailure = null;
1663
+ this.availableKeyCount = 0;
1664
+ }
1665
+ /**
1666
+ * When the last **successful** fetch happened, in epoch ms, or `null`.
1667
+ *
1668
+ * FR-13's staleness clock starts here: an attempt that failed does not move
1669
+ * this value, which is exactly why cached keys survive a provider outage.
1670
+ */
1671
+ get lastRefreshedAt() {
1672
+ return this.refreshedAtMs;
1673
+ }
1674
+ /** When the last fetch was **attempted**, successful or not, or `null`. */
1675
+ get lastAttemptAt() {
1676
+ return this.lastAttemptAtMs;
1677
+ }
1678
+ /** Why the last fetch failed, or `null` when the last one succeeded. */
1679
+ get lastFetchFailure() {
1680
+ return this.lastFailure;
1681
+ }
1682
+ /** How many keys the cached set holds; `0` until the first successful fetch. */
1683
+ get keyCount() {
1684
+ return this.availableKeyCount;
1685
+ }
1686
+ /**
1687
+ * Verify a compact JWS against the cached key set, and answer its claims.
1688
+ *
1689
+ * The order is the plan §4.3 order and each step is load-bearing:
1690
+ *
1691
+ * 1. the protected header must parse (a truncated token is `badSignature`);
1692
+ * 2. `alg` must be one of FR-11's three — `none` and `HS256` are refused
1693
+ * **before** any key lookup, so a token can never talk us into treating a
1694
+ * public key as a shared secret;
1695
+ * 3. the key comes from the cache, refreshing once under FR-13's rules;
1696
+ * 4. `compactVerify` checks the signature, with the same allow-list handed to
1697
+ * it so the two can never disagree.
1698
+ *
1699
+ * Claim rules are the caller's (see the class docstring).
1700
+ */
1701
+ async verify(token) {
1702
+ const header = decodeProtectedHeaderOrNull(token);
1703
+ if (header === null) throw new OidcJwksVerificationError("badSignature");
1704
+ const algorithms = this.algorithms();
1705
+ const alg = header.alg;
1706
+ if (typeof alg !== "string" || !algorithms.includes(alg)) {
1707
+ throw new OidcJwksVerificationError("badAlg");
1708
+ }
1709
+ const key = await this.resolveSigningKey(header, token);
1710
+ try {
1711
+ const { payload } = await compactVerify(token, key, { algorithms: [...algorithms] });
1712
+ return {
1713
+ header,
1714
+ payload: JSON.parse(new TextDecoder().decode(payload)),
1715
+ kid: typeof header.kid === "string" ? header.kid : null
1716
+ };
1717
+ } catch {
1718
+ throw new OidcJwksVerificationError("badSignature");
1719
+ }
1720
+ }
1721
+ /**
1722
+ * The key for a token's header, refreshing at most once when the `kid` is unknown
1723
+ * (FR-13).
1724
+ *
1725
+ * A `kid` nobody published cannot be served from the cache, and the two ways out
1726
+ * are ranked: refresh if FR-13's cooldown allows it, and otherwise refuse. A
1727
+ * refusal is `badSignature` — never `providerUnavailable` — while the cached set
1728
+ * is still inside its 21,600 seconds, because the set *is* trustworthy and the
1729
+ * token is simply not signed by anything in it.
1730
+ */
1731
+ async resolveSigningKey(header, token) {
1732
+ await this.ensureUsableKeySet();
1733
+ const direct = await this.findKey(header, token);
1734
+ if (direct !== null) return direct;
1735
+ if (this.canRefreshForUnknownKid()) {
1736
+ this.lastUnknownKidRefreshAtMs = this.now();
1737
+ const refreshed = await this.refresh();
1738
+ if (refreshed) {
1739
+ const afterRefresh = await this.findKey(header, token);
1740
+ if (afterRefresh !== null) return afterRefresh;
1741
+ }
1742
+ }
1743
+ this.assertTrusted();
1744
+ throw new OidcJwksVerificationError("badSignature");
1745
+ }
1746
+ /**
1747
+ * Fetch the key set now, replacing the cache **only** on success.
1748
+ *
1749
+ * Answers whether a usable set is in place afterwards. A failed fetch leaves the
1750
+ * previous set exactly as it was, which is what FR-13's 21,600-second window
1751
+ * describes; it never clears the cache and it never throws to a caller that has a
1752
+ * token to refuse.
1753
+ */
1754
+ async refresh() {
1755
+ this.lastAttemptAtMs = this.now();
1756
+ const result = await fetchJsonWithOneRetry(
1757
+ { url: this.options.jwksUri },
1758
+ {
1759
+ fetchImpl: this.options.fetchImpl,
1760
+ now: this.options.now,
1761
+ timeoutMs: this.options.timeoutMs ?? OIDC_OUTBOUND_TIMEOUT_MS,
1762
+ retryDelayMs: this.options.retryDelayMs
1763
+ }
1764
+ );
1765
+ if (!result.ok) {
1766
+ this.lastFailure = result.failure ?? "network";
1767
+ return false;
1768
+ }
1769
+ const jwks = asJwks(result.body);
1770
+ if (jwks === null) {
1771
+ this.lastFailure = "invalidDocument";
1772
+ return false;
1773
+ }
1774
+ this.jwks = jwks;
1775
+ this.resolveKey = createLocalJWKSet(jwks);
1776
+ this.refreshedAtMs = this.now();
1777
+ this.availableKeyCount = jwks.keys.length;
1778
+ this.lastFailure = null;
1779
+ this.options.onRefreshed?.(this.refreshedAtMs);
1780
+ return true;
1781
+ }
1782
+ /**
1783
+ * Make sure the cache is fresh enough to answer from (FR-13's 600 seconds).
1784
+ *
1785
+ * An expired cache is refreshed; a cache that cannot be refreshed stays usable
1786
+ * while {@link assertTrusted} allows it, and throws `providerUnavailable` once it
1787
+ * does not.
1788
+ */
1789
+ async ensureUsableKeySet() {
1790
+ if (this.jwks !== null && this.isCacheFresh()) return;
1791
+ await this.refresh();
1792
+ this.assertTrusted();
1793
+ }
1794
+ /** FR-13's 600 seconds: `age < cacheSeconds`. */
1795
+ isCacheFresh() {
1796
+ if (this.refreshedAtMs === null) return false;
1797
+ return this.now() - this.refreshedAtMs < this.cacheSeconds() * 1e3;
1798
+ }
1799
+ /**
1800
+ * FR-13's 21,600 seconds, and the fail-closed end of the ladder.
1801
+ *
1802
+ * Two distinct refusals, because they are two distinct operational facts: no key
1803
+ * set has ever been fetched (`keysUnavailable` — usually a provider that has
1804
+ * never answered), or the last good fetch is older than plan §4.3's window
1805
+ * (`keysStale` — plan §9.2's "JWKS stale beyond 21,600 s: every validation fails
1806
+ * closed").
1807
+ */
1808
+ assertTrusted() {
1809
+ if (this.jwks === null || this.refreshedAtMs === null) {
1810
+ throw new OidcProviderUnavailableError("keysUnavailable");
1811
+ }
1812
+ if (this.now() - this.refreshedAtMs > this.maxStaleSeconds() * 1e3) {
1813
+ throw new OidcProviderUnavailableError("keysStale");
1814
+ }
1815
+ }
1816
+ /**
1817
+ * FR-13's "at most one refresh per 30 seconds" for an unknown key id.
1818
+ *
1819
+ * Read as the rule it protects: the unknown-`kid` refetch is the one an attacker
1820
+ * can trigger at will by presenting a token with a made-up `kid`, so that trigger
1821
+ * is the one the cooldown bounds. A *successful* cache refresh is not counted
1822
+ * against it, because the 600-second cache already bounds those — counting them
1823
+ * would refuse the first token signed by a freshly rotated key for up to 30
1824
+ * seconds after any refresh, which is the opposite of ACC-12-08 ("a token signed
1825
+ * with a newly rotated key validates after one key refresh").
1826
+ */
1827
+ canRefreshForUnknownKid() {
1828
+ if (this.lastUnknownKidRefreshAtMs === null) return true;
1829
+ return this.now() - this.lastUnknownKidRefreshAtMs >= this.unknownKidCooldownSeconds() * 1e3;
1830
+ }
1831
+ /**
1832
+ * Ask jose for the key matching a header, or `null` when nothing matches.
1833
+ *
1834
+ * `JWKSNoMatchingKey` is the expected "unknown kid" answer and becomes `null` so
1835
+ * the caller can try FR-13's one refresh. Every other jose failure — a malformed
1836
+ * set, several keys matching one header — is a refusal, and none of them ever
1837
+ * surfaces with jose's message attached (FR-16).
1838
+ */
1839
+ async findKey(header, token) {
1840
+ const resolve = this.resolveKey;
1841
+ if (resolve === null) return null;
1842
+ try {
1843
+ return await resolve(header, token);
1844
+ } catch (error) {
1845
+ if (error instanceof errors.JWKSNoMatchingKey) return null;
1846
+ throw new OidcJwksVerificationError("badSignature");
1847
+ }
1848
+ }
1849
+ algorithms() {
1850
+ return this.options.algorithms ?? OIDC_IDENTITY_SIGNING_ALGS;
1851
+ }
1852
+ cacheSeconds() {
1853
+ return this.options.cacheSeconds ?? OIDC_JWKS_CACHE_SECONDS;
1854
+ }
1855
+ maxStaleSeconds() {
1856
+ return this.options.maxStaleSeconds ?? OIDC_JWKS_MAX_STALE_SECONDS;
1857
+ }
1858
+ unknownKidCooldownSeconds() {
1859
+ return this.options.unknownKidCooldownSeconds ?? OIDC_JWKS_UNKNOWN_KID_COOLDOWN_SECONDS;
1860
+ }
1861
+ now() {
1862
+ return (this.options.now ?? Date.now)();
1863
+ }
1864
+ };
1865
+ function decodeProtectedHeaderOrNull(token) {
1866
+ try {
1867
+ return decodeProtectedHeader(token);
1868
+ } catch {
1869
+ return null;
1870
+ }
1871
+ }
1872
+ function asJwks(value) {
1873
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
1874
+ const keys = value.keys;
1875
+ if (!Array.isArray(keys)) return null;
1876
+ return value;
1877
+ }
1878
+
1879
+ // src/scopes.ts
1880
+ var OIDC_SIGN_IN_SCOPES = ["openid", "email", "profile"];
1881
+ var OIDC_SIGN_IN_SCOPE = OIDC_SIGN_IN_SCOPES.join(" ");
1882
+ var OIDC_NEVER_REQUESTED_SCOPES = ["offline_access"];
1883
+ var OIDC_DELEGATED_READ_SCOPE = "apps:read";
1884
+ var OIDC_SESSION_EXCHANGE_SCOPE = "ever-works:session";
1885
+ var OIDC_TOKEN_SCOPES = [OIDC_DELEGATED_READ_SCOPE, OIDC_SESSION_EXCHANGE_SCOPE];
1886
+ function isSignInScope(value) {
1887
+ const requested = value.split(/\s+/u).filter((scope) => scope.length > 0);
1888
+ if (requested.length !== OIDC_SIGN_IN_SCOPES.length) return false;
1889
+ const unique = new Set(requested);
1890
+ if (unique.size !== requested.length) return false;
1891
+ return OIDC_SIGN_IN_SCOPES.every((scope) => unique.has(scope));
1892
+ }
1893
+
1894
+ // src/settings.schema.ts
1895
+ var oidcIdentityIssuerUrlPattern = "^(https://[^\\s]+|http://(localhost|127\\.0\\.0\\.1)(:\\d{1,5})?(/[^\\s]*)?)$";
1896
+ var oidcIdentityHttpsUrlPattern = "^https://[^\\s]+$";
1897
+ var OIDC_IDENTITY_SETTING_KEYS = [
1898
+ "issuerUrl",
1899
+ "clientId",
1900
+ "clientSecret",
1901
+ "allowedIssuers",
1902
+ "apiAudience",
1903
+ "localClients",
1904
+ "delegatedClientNames",
1905
+ "accountManagementUrl",
1906
+ "signUpAllowed",
1907
+ "clockSkewSeconds",
1908
+ "displayName",
1909
+ "availability"
1910
+ ];
1911
+ var oidcIdentitySettingsSchema = {
1912
+ type: "object",
1913
+ title: "Ever ID",
1914
+ description: "OpenID Connect relying-party configuration for Ever ID. One issuer set per installation: browser sign-in, connected identities and delegated reads all use it (FR-2, FR-7).",
1915
+ properties: {
1916
+ issuerUrl: {
1917
+ type: "string",
1918
+ title: "Issuer address",
1919
+ description: "The provider issuer, e.g. https://auth.ever.co. Must be https; http is accepted only for localhost and is refused outside development (FR-2). Register exactly one redirect address at the provider (FR-10).",
1920
+ pattern: oidcIdentityIssuerUrlPattern,
1921
+ "x-envVar": "EVER_ID_ISSUER_URL",
1922
+ "x-scope": "global"
1923
+ },
1924
+ clientId: {
1925
+ type: "string",
1926
+ title: "Client ID",
1927
+ description: "The relying-party client identifier Ever ID issued to this installation (FR-2).",
1928
+ maxLength: 255,
1929
+ "x-envVar": "EVER_ID_CLIENT_ID",
1930
+ "x-scope": "global"
1931
+ },
1932
+ clientSecret: {
1933
+ type: "string",
1934
+ title: "Client secret",
1935
+ description: "The client secret used with client_secret_basic. Write-only: never returned by any response, log, Activity row or telemetry payload (FR-16, ACC-12-03).",
1936
+ "x-secret": true,
1937
+ "x-envVar": "EVER_ID_CLIENT_SECRET",
1938
+ "x-scope": "global"
1939
+ },
1940
+ allowedIssuers: {
1941
+ type: "array",
1942
+ title: "Allowed issuers",
1943
+ description: "1\u20133 exact issuer strings accepted at once, so a planned provider move stays reversible. Defaults to the configured issuer address when unset (FR-2, FR-14).",
1944
+ items: { type: "string" },
1945
+ minItems: 1,
1946
+ maxItems: 3,
1947
+ "x-envVar": "EVER_ID_ALLOWED_ISSUERS",
1948
+ "x-scope": "global"
1949
+ },
1950
+ apiAudience: {
1951
+ type: "string",
1952
+ title: "API audience",
1953
+ description: "The audience a delegated or exchanged access token must contain (FR-40, FR-45). Defaults to ever-works.",
1954
+ maxLength: 255,
1955
+ default: "ever-works",
1956
+ "x-envVar": "EVER_ID_API_AUDIENCE",
1957
+ "x-scope": "global"
1958
+ },
1959
+ localClients: {
1960
+ type: "array",
1961
+ title: "Local clients",
1962
+ description: "Public clients (CLI, node) allowed to exchange an Ever ID access token for an Ever Works session, at most 5 (FR-2, FR-39, FR-40).",
1963
+ items: {
1964
+ type: "object",
1965
+ properties: {
1966
+ kind: {
1967
+ type: "string",
1968
+ title: "Client kind",
1969
+ description: "Which local client this entry configures.",
1970
+ enum: ["cli", "node"]
1971
+ },
1972
+ clientId: {
1973
+ type: "string",
1974
+ title: "Client ID",
1975
+ description: "The public client identifier registered at the provider."
1976
+ }
1977
+ },
1978
+ required: ["kind", "clientId"]
1979
+ },
1980
+ maxItems: 5,
1981
+ default: [],
1982
+ "x-scope": "global"
1983
+ },
1984
+ delegatedClientNames: {
1985
+ type: "array",
1986
+ title: "Delegated client names",
1987
+ description: "The names the Connected identities card shows for apps that used a delegated read, at most 10 (FR-48). A client id with no name here is shown as the client id itself.",
1988
+ items: {
1989
+ type: "object",
1990
+ properties: {
1991
+ clientId: {
1992
+ type: "string",
1993
+ title: "Client ID",
1994
+ maxLength: 255
1995
+ },
1996
+ displayName: {
1997
+ type: "string",
1998
+ title: "Display name",
1999
+ maxLength: 60
2000
+ }
2001
+ },
2002
+ required: ["clientId"]
2003
+ },
2004
+ maxItems: 10,
2005
+ default: [],
2006
+ "x-scope": "global"
2007
+ },
2008
+ accountManagementUrl: {
2009
+ type: "string",
2010
+ title: "Account management address",
2011
+ description: "The target of the Manage in Ever ID link on the Connected identities card (FR-48). Must be https; when unset the link is hidden.",
2012
+ pattern: oidcIdentityHttpsUrlPattern,
2013
+ "x-scope": "global"
2014
+ },
2015
+ signUpAllowed: {
2016
+ type: "boolean",
2017
+ title: "Allow sign-up with Ever ID",
2018
+ description: "Whether an unknown, verified Ever ID identity may create an account (FR-2, FR-23). Defaults to on.",
2019
+ default: true,
2020
+ "x-envVar": "EVER_ID_SIGN_UP_ALLOWED",
2021
+ "x-scope": "global"
2022
+ },
2023
+ clockSkewSeconds: {
2024
+ type: "integer",
2025
+ title: "Clock skew tolerance (seconds)",
2026
+ description: "Tolerance applied to every token time check, 0\u2013120 seconds (FR-2, FR-11). Defaults to 60.",
2027
+ minimum: 0,
2028
+ maximum: 120,
2029
+ default: 60,
2030
+ "x-envVar": "EVER_ID_CLOCK_SKEW_SECONDS",
2031
+ "x-scope": "global"
2032
+ },
2033
+ displayName: {
2034
+ type: "string",
2035
+ title: "Display name",
2036
+ description: "The button and heading label, at most 40 characters (FR-2). Defaults to Ever ID.",
2037
+ maxLength: 40,
2038
+ default: "Ever ID",
2039
+ "x-scope": "global"
2040
+ },
2041
+ availability: {
2042
+ type: "object",
2043
+ title: "Availability",
2044
+ description: "Platform-written availability state: when the provider was last seen unavailable and when discovery, the key set and the last sign-out notice were refreshed. Not user-writable; stored so every replica agrees within 60 seconds (FR-5, plan \xA79.2).",
2045
+ properties: {
2046
+ unavailableSince: {
2047
+ type: "string",
2048
+ description: "Set when Test connection fails; cleared by the next passing run (FR-3, FR-5).",
2049
+ format: "date-time"
2050
+ },
2051
+ discoveryRefreshedAt: {
2052
+ type: "string",
2053
+ description: "When the discovery document was last fetched successfully (FR-14).",
2054
+ format: "date-time"
2055
+ },
2056
+ jwksRefreshedAt: {
2057
+ type: "string",
2058
+ description: "When the provider key set was last fetched successfully (FR-13).",
2059
+ format: "date-time"
2060
+ },
2061
+ lastLogoutNoticeAt: {
2062
+ type: "string",
2063
+ description: "When the last back-channel logout notice was accepted (FR-33).",
2064
+ format: "date-time"
2065
+ }
2066
+ },
2067
+ "x-hidden": true,
2068
+ "x-scope": "global"
2069
+ }
2070
+ },
2071
+ required: ["issuerUrl", "clientId", "clientSecret"]
2072
+ };
2073
+
2074
+ // src/oidc-identity.plugin.ts
2075
+ var FR3_CHECK_IDS = [
2076
+ "discovery",
2077
+ "issuerMatch",
2078
+ "endpoints",
2079
+ "pkceS256",
2080
+ "signingAlg",
2081
+ "backchannelLogout",
2082
+ "deviceAuthorization"
2083
+ ];
2084
+ var FR3_REQUIRED_CHECK_IDS = [
2085
+ "discovery",
2086
+ "issuerMatch",
2087
+ "endpoints",
2088
+ "pkceS256",
2089
+ "signingAlg"
2090
+ ];
2091
+ var OIDC_STATE_BYTES = 32;
2092
+ var OIDC_NONCE_BYTES = 32;
2093
+ var OIDC_CODE_VERIFIER_LENGTH = 64;
2094
+ var OIDC_CODE_VERIFIER_BYTES = 48;
2095
+ var OIDC_DEFAULT_CLOCK_SKEW_SECONDS = 60;
2096
+ var OIDC_MAX_CLOCK_SKEW_SECONDS = 120;
2097
+ var OIDC_ID_TOKEN_MAX_AGE_SECONDS = 600;
2098
+ var OIDC_SUBJECT_MAX_LENGTH = 255;
2099
+ var OIDC_DEFAULT_API_AUDIENCE = "ever-works";
2100
+ var OIDC_ACCESS_TOKEN_MAX_LIFETIME_SECONDS = 3600;
2101
+ var OIDC_ACCESS_TOKEN_MAX_AGE_SECONDS = 300;
2102
+ var OIDC_LOGOUT_TOKEN_MAX_AGE_SECONDS = 300;
2103
+ var OIDC_LOGOUT_TOKEN_REPLAY_WINDOW_SECONDS = 600;
2104
+ var OIDC_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
2105
+ var OidcIdentityPlugin = class {
2106
+ constructor(options = {}) {
2107
+ this.id = "oidc-identity";
2108
+ this.name = "OpenID Connect identity (Ever ID)";
2109
+ this.version = "1.0.0";
2110
+ /**
2111
+ * The plan §4.2 category, `'identity'`.
2112
+ *
2113
+ * The assertion is needed only because this package lands **before T4**,
2114
+ * which appends `'identity'` to `PLUGIN_CATEGORIES`
2115
+ * (`packages/plugin/src/contracts/plugin-manifest.types.ts:5-117` — the tuple
2116
+ * currently closes with `'build'`). Until that append lands the loader's own
2117
+ * `isPluginCategory` check rejects the manifest, so this plugin is
2118
+ * discoverable only after T4; the value itself is already exact, and the
2119
+ * assertion is the one place the type union is narrower than the plan.
2120
+ *
2121
+ * **Landed 2026-09-18 (T4).** `'identity'` is now the **last** member of
2122
+ * `PLUGIN_CATEGORIES`, so the loader's `isPluginCategory` accepts this
2123
+ * manifest and discovery finds the package instead of skipping it with
2124
+ * `Invalid category` (T4's report measures it: 103 → 104 plugins). The
2125
+ * paragraph above is kept because that is what this file had to say while
2126
+ * the append was missing, and the cast below stays — it resolves to the
2127
+ * same member, and dropping it would remove an assertion for nothing.
2128
+ */
2129
+ this.category = "identity";
2130
+ /** Plan §4.2: exactly one capability, `identity-provider`. */
2131
+ this.capabilities = ["identity-provider"];
2132
+ /** One issuer set per installation, configured by a platform admin (FR-7, FR-2). */
2133
+ this.configurationMode = "admin-only";
2134
+ this.settingsSchema = oidcIdentitySettingsSchema;
2135
+ /** FR-13 / plan §9.2: when the key set was last fetched successfully, in epoch ms. T32 persists it. */
2136
+ this.jwksRefreshedAtMs = null;
2137
+ /** FR-14/FR-5: when the provider was first seen unusable, in epoch ms. T32 persists it. */
2138
+ this.unavailableSinceMs = null;
2139
+ /** Which rule made it unusable — a closed set, never a provider's own text (FR-16). */
2140
+ this.unavailableReason = null;
2141
+ this.fetchImpl = options.fetchImpl;
2142
+ this.now = options.now ?? Date.now;
2143
+ this.randomBytes = options.randomBytes;
2144
+ this.nodeEnv = options.nodeEnv;
2145
+ }
2146
+ async onLoad(context) {
2147
+ this.context = context;
2148
+ context.logger.log(
2149
+ "OpenID Connect identity (Ever ID) plugin loaded \u2014 discovery, key cache, Test connection, the sign-in flow and the token verifiers (access, logout, end-session) are all available"
2150
+ );
2151
+ }
2152
+ async onUnload() {
2153
+ this.context = void 0;
2154
+ this.discoveryReader = void 0;
2155
+ this.discoveryIssuerUrl = void 0;
2156
+ this.jwksCache = void 0;
2157
+ this.jwksUri = void 0;
2158
+ this.jwksRefreshedAtMs = null;
2159
+ }
2160
+ /**
2161
+ * FR-3 — read the provider's discovery document and report every check.
2162
+ *
2163
+ * The rules this method exists to keep, in the order they matter:
2164
+ *
2165
+ * - **5 seconds, always** (FR-3, ACC-12-03). The read is handed a deadline
2166
+ * computed when the run starts, so FR-15's single retry can only ever fit
2167
+ * inside that budget, and the answer is reported at the deadline whether or
2168
+ * not the provider answered in time.
2169
+ * - **A row per check, always** — the same seven ids in FR-3's order, an
2170
+ * unconfigured integration and an unreachable provider included. A short or
2171
+ * varying list would make spec §6.7's admin surface render differently
2172
+ * depending on what went wrong.
2173
+ * - **Never the secret** (FR-16). No `detail` quotes a value the provider sent
2174
+ * except a numeric HTTP status; every one of them names a field, and the
2175
+ * settings object — which is where `clientSecret` lives — is never returned,
2176
+ * logged or embedded.
2177
+ *
2178
+ * The run also writes the availability record plan §4.2 keeps: the five checks
2179
+ * of {@link FR3_REQUIRED_CHECK_IDS} passing clears `unavailableSince`, any of them
2180
+ * failing sets it (FR-14's "until an administrator re-tests"). T32 persists it;
2181
+ * {@link getAvailability} is the in-process view until then.
2182
+ */
2183
+ async testConnection() {
2184
+ const resolved = await this.resolveSettings();
2185
+ if (!resolved.ok) {
2186
+ this.recordUnavailable("notConfigured");
2187
+ return unavailableRows(
2188
+ `Not configured: ${resolved.missing.join(", ")}.`,
2189
+ "Not checked \u2014 the integration is not configured."
2190
+ );
2191
+ }
2192
+ const issuerUrl = resolved.settings.issuerUrl;
2193
+ const deadlineAt = this.now() + OIDC_OUTBOUND_TIMEOUT_MS;
2194
+ const read = await this.readerFor(issuerUrl).read({ deadlineAt });
2195
+ const checks = read.ok ? checksFromDocument(read.document, issuerUrl) : unavailableRows(
2196
+ discoveryFailureDetail(read.failure, read.status),
2197
+ "Not checked \u2014 the discovery document could not be read."
2198
+ );
2199
+ this.applyAvailability(checks);
2200
+ this.logChecks(checks);
2201
+ return checks;
2202
+ }
2203
+ /**
2204
+ * FR-2's non-secret projection — what the web needs to render the button and
2205
+ * what a local client needs before it can sign in.
2206
+ *
2207
+ * The contract's five fields and nothing else: the client secret is not among
2208
+ * them (FR-16), and neither is anything that would let a caller address the
2209
+ * provider differently from the configuration an administrator saved (FR-7). The
2210
+ * defaults are FR-2's: display name `Ever ID`, audience `ever-works`, sign-up on.
2211
+ */
2212
+ async getPublicConfig() {
2213
+ const resolved = await this.resolveSettings();
2214
+ if (!resolved.ok) {
2215
+ throw new OidcProviderUnavailableError("notConfigured");
2216
+ }
2217
+ const settings = resolved.settings;
2218
+ return {
2219
+ issuer: settings.issuerUrl,
2220
+ displayName: nonEmpty(settings.displayName) ?? "Ever ID",
2221
+ localClients: localClientsOf(settings.localClients),
2222
+ apiAudience: nonEmpty(settings.apiAudience) ?? OIDC_DEFAULT_API_AUDIENCE,
2223
+ // FR-2's default is on, so only an explicit `false` turns sign-up off.
2224
+ signUpAllowed: settings.signUpAllowed !== false
2225
+ };
2226
+ }
2227
+ /**
2228
+ * FR-8/FR-9/FR-10/FR-12's first half — the authorization request a browser is
2229
+ * sent to, and the three secrets the transaction cookie carries.
2230
+ *
2231
+ * What this method is responsible for, and what it is deliberately not:
2232
+ *
2233
+ * - **PKCE S256 only** (FR-8). `code_challenge_method=S256` is a literal in
2234
+ * the parameters below and the challenge is the SHA-256 of the verifier,
2235
+ * computed by `openid-client`'s `calculatePKCECodeChallenge` — the same
2236
+ * transformation the provider will apply to the verifier at the token
2237
+ * endpoint. `plain` is never offered and never sent; a provider that
2238
+ * advertises only `plain` is refused by FR-3's check row and by the
2239
+ * availability record, not by this method.
2240
+ * - **Fresh `state` and `nonce` of 32 random bytes, and a 64-character
2241
+ * verifier, per call** (FR-9). Every call draws new bytes from
2242
+ * {@link OidcIdentityPluginOptions.randomBytes} (the platform CSPRNG by
2243
+ * default) and encodes them base64url: 32 bytes → a 43-character value
2244
+ * whose decoded length is exactly the 32 FR-9 asks for, 48 bytes → exactly
2245
+ * the 64 characters FR-9 asks of the verifier. Nothing is reused between
2246
+ * calls and nothing is derived from the clock, the issuer or the client
2247
+ * id — a `state` an attacker can predict is a `state` that binds nothing.
2248
+ * - **The exact redirect address, never rebuilt** (FR-10). `redirectUri` is
2249
+ * copied into the query parameter byte-for-byte: no trailing-slash
2250
+ * normalisation, no parsing and re-serialising, no default. Deriving that
2251
+ * address from the configured public web address — and refusing anything
2252
+ * that came from request input — is the caller's rule (plan §5.1's
2253
+ * `POST /authorize`), and this method cannot enforce it because the
2254
+ * address is not part of the plugin's settings (plan §4.2 has no redirect
2255
+ * key: the one address per deployment lives in platform configuration).
2256
+ * - **`openid email profile`, and never `offline_access`** (plan §4.2,
2257
+ * FR-38) — the scope comes from `src/scopes.ts` as one constant rather
2258
+ * than being assembled here.
2259
+ *
2260
+ * The endpoint comes from the discovery document this plugin already read
2261
+ * (FR-14's 3,600-second cache, and its issuer-drift refusal): a request is
2262
+ * never built against an issuer whose document advertises a different issuer,
2263
+ * so a provider cannot move this installation's sign-in by publishing a
2264
+ * document somewhere else.
2265
+ *
2266
+ * Errors are T6's availability vocabulary rather than token rejections —
2267
+ * nothing here verifies a token, and a caller needs to know *which*
2268
+ * configuration or document problem stopped the sign-in from starting:
2269
+ * `notConfigured`, `discoveryFailed`, `issuerDrift` or `discoveryIncomplete`
2270
+ * (a document with no `authorization_endpoint`). Every one of them maps to
2271
+ * plan §5.2's `404 everIdDisabled` / `503 providerUnavailable` at the façade.
2272
+ */
2273
+ async buildAuthorizationRequest(input) {
2274
+ const resolved = await this.resolveSettings();
2275
+ if (!resolved.ok) throw new OidcProviderUnavailableError("notConfigured");
2276
+ const settings = resolved.settings;
2277
+ const document = await this.readerFor(settings.issuerUrl).get();
2278
+ if (typeof document.authorization_endpoint !== "string" || document.authorization_endpoint.length === 0) {
2279
+ throw new OidcProviderUnavailableError("discoveryIncomplete");
2280
+ }
2281
+ const state = encodeBase64Url(this.random(OIDC_STATE_BYTES));
2282
+ const nonce = encodeBase64Url(this.random(OIDC_NONCE_BYTES));
2283
+ const codeVerifier = encodeBase64Url(this.random(OIDC_CODE_VERIFIER_BYTES));
2284
+ const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
2285
+ const url = buildAuthorizationUrl(this.configurationFor(document, settings), {
2286
+ response_type: "code",
2287
+ client_id: settings.clientId,
2288
+ redirect_uri: input.redirectUri,
2289
+ scope: OIDC_SIGN_IN_SCOPE,
2290
+ state,
2291
+ nonce,
2292
+ code_challenge: codeChallenge,
2293
+ code_challenge_method: "S256",
2294
+ // FR-25's connect path asks for a fresh authentication (`prompt=login`) and
2295
+ // bounds how old it may be (`max_age`); a plain sign-in sends neither, and
2296
+ // an absent parameter is not the same wire value as an empty one.
2297
+ ...input.prompt === void 0 ? {} : { prompt: input.prompt },
2298
+ ...input.maxAgeSeconds === void 0 ? {} : { max_age: String(input.maxAgeSeconds) }
2299
+ });
2300
+ return { url: url.toString(), state, nonce, codeVerifier };
2301
+ }
2302
+ /**
2303
+ * FR-11/FR-12 — redeem an authorization code and answer the ID token's claims,
2304
+ * or refuse.
2305
+ *
2306
+ * The order below is the spec's order, and each step is load-bearing:
2307
+ *
2308
+ * 1. **the settings resolve** — an unconfigured integration cannot exchange
2309
+ * anything, and this method has no way to say so except
2310
+ * `providerUnavailable` (see the error contract below);
2311
+ * 2. **FR-12's `iss` equality, before anything is redeemed.** When the
2312
+ * authorization response carries `iss`, it must equal the configured
2313
+ * issuer exactly. Checking it first is not an optimisation: a code whose
2314
+ * response named another issuer is a code this installation must not
2315
+ * spend, and a refusal here touches the network zero times;
2316
+ * 3. **the discovery document** (FR-14's cache, and its issuer-drift
2317
+ * refusal) — the token endpoint and `jwks_uri` come from it;
2318
+ * 4. **the code exchange** — one POST, `client_secret_basic`, 5,000 ms, and
2319
+ * **no retry** (FR-15's "the code exchange is never retried": a second
2320
+ * attempt would be a second redemption of a single-use code, which is
2321
+ * FR-19's "a sign-in transaction completes at most once" seen from the
2322
+ * provider's side);
2323
+ * 5. **the signature and the algorithm**, through T6's key cache
2324
+ * (`jwks-cache.ts`), whose three codes and whose 21,600-second
2325
+ * fail-closed end are reported with the same codes here;
2326
+ * 6. **FR-11's claim rules** (plan §4.3), against the injected clock and the
2327
+ * administrator's `clockSkewSeconds` (default
2328
+ * {@link OIDC_DEFAULT_CLOCK_SKEW_SECONDS}).
2329
+ *
2330
+ * ## The error contract, and why it differs from {@link buildAuthorizationRequest}
2331
+ *
2332
+ * **Everything this method throws is an `IdentityTokenRejectedError`** — the
2333
+ * contract's single refusal type, `message` equal to `code` and nothing else
2334
+ * (FR-16). That includes the provider-unavailable conditions: T6's discovery
2335
+ * and key-cache errors are caught below and rethrown as
2336
+ * `providerUnavailable`, and any `jwks-cache` refusal keeps its own code
2337
+ * (`badAlg`, `badSignature`). The reason is the caller: the callback handler
2338
+ * has one decision to make — plan §5.2's "ID token rejected → 401" versus
2339
+ * "endpoint unavailable → 503" — and one error type with a closed code set is
2340
+ * the shape that decision is written against. The builder, which answers a
2341
+ * question rather than a verdict, keeps the precise availability reason
2342
+ * instead.
2343
+ *
2344
+ * A refusal never partially succeeds: nothing is returned, nothing is stored,
2345
+ * and no claim of a refused token reaches the caller.
2346
+ *
2347
+ * ## `maxAuthAgeSeconds` (FR-25)
2348
+ *
2349
+ * Supplied, it applies FR-25's rule — `auth_time` no older than 300 seconds
2350
+ * plus the skew — and answers `tooOld`, including when the token carries no
2351
+ * `auth_time` at all: a connect flow that cannot show the authentication was
2352
+ * recent must re-authenticate rather than proceed. Absent (a plain sign-in),
2353
+ * `auth_time` is not required and is passed through as `null` when missing.
2354
+ */
2355
+ async exchangeAuthorizationCode(input) {
2356
+ const resolved = await this.resolveSettings();
2357
+ if (!resolved.ok) throw new IdentityTokenRejectedError("providerUnavailable");
2358
+ const settings = resolved.settings;
2359
+ if (input.receivedIssuer !== void 0 && input.receivedIssuer !== settings.issuerUrl) {
2360
+ throw new IdentityTokenRejectedError("badIssuer");
2361
+ }
2362
+ const document = await this.discoveryForVerification(settings.issuerUrl);
2363
+ const tokenEndpoint = nonEmpty(document.token_endpoint);
2364
+ const jwksUri = nonEmpty(document.jwks_uri);
2365
+ if (tokenEndpoint === void 0 || jwksUri === void 0) {
2366
+ throw new IdentityTokenRejectedError("providerUnavailable");
2367
+ }
2368
+ const idToken = await this.redeemAuthorizationCode(input, settings, tokenEndpoint);
2369
+ const verification = await this.verifyProviderSignature(idToken, jwksUri);
2370
+ return idTokenClaims(verification.payload, {
2371
+ issuer: document.issuer,
2372
+ clientId: settings.clientId,
2373
+ // FR-2's default when an administrator configured no allow-list: the one
2374
+ // issuer this installation accepts is its own.
2375
+ allowedIssuers: settings.allowedIssuers ?? [settings.issuerUrl],
2376
+ expectedNonce: input.expectedNonce,
2377
+ skewSeconds: settings.clockSkewSeconds ?? OIDC_DEFAULT_CLOCK_SKEW_SECONDS,
2378
+ nowSeconds: Math.floor(this.now() / 1e3),
2379
+ maxAuthAgeSeconds: input.maxAuthAgeSeconds
2380
+ });
2381
+ }
2382
+ /**
2383
+ * FR-40 (the local-client exchange) and FR-45 (the App Launcher's delegated
2384
+ * read) — verify an Ever ID access token and answer the claims both callers
2385
+ * branch on.
2386
+ *
2387
+ * The rules, in the order they are applied, and where each one comes from:
2388
+ *
2389
+ * 1. **the settings resolve** — as in {@link exchangeAuthorizationCode}, an
2390
+ * unconfigured integration answers `providerUnavailable`, the only code
2391
+ * this method has for "the plugin cannot ask the provider anything";
2392
+ * 2. **the discovery document** (FR-14's cache and issuer-drift refusal) and
2393
+ * its `jwks_uri` — the same read the exchange performs, and the same
2394
+ * `providerUnavailable` when the document names no key set;
2395
+ * 3. **the signature and the algorithm** through T6's key cache, including
2396
+ * FR-13's ladder and its fail-closed end (`badAlg`, `badSignature`,
2397
+ * `providerUnavailable`);
2398
+ * 4. **FR-11's issuer rule**, which FR-45 makes a rule for access tokens too:
2399
+ * `iss` equals the discovery issuer **and** is allow-listed (`badIssuer`);
2400
+ * 5. **the audience** — `aud` contains the configured API audience, or plan
2401
+ * §4.2's `ever-works` default (`badAudience`);
2402
+ * 6. **the scopes** — every entry of `requiredScopes` is present (`missingScope`,
2403
+ * FR-46's code: a valid token that lacks the scope its caller needs);
2404
+ * 7. **the times** — a readable `exp` that has not passed (with the skew), an
2405
+ * `iat` no later than now plus the skew, and an `iat` no older than
2406
+ * `maxAgeSeconds` when the caller gives one (`expired`, `notYetValid`,
2407
+ * `tooOld`);
2408
+ * 8. **the lifetime ceiling** — `exp − iat ≤ maxLifetimeSeconds`
2409
+ * (`lifetimeTooLong`; ACC-12-35's "lifetime over 3,600 seconds");
2410
+ * 9. **the authorised party** — when `allowedAuthorizedParties` is given (the
2411
+ * exchange path, FR-40), `azp` must be one of them
2412
+ * (`badAuthorizedParty`); absent, the claim is not read at all, which is
2413
+ * FR-45's rule set exactly.
2414
+ *
2415
+ * ## The two arguments that make one method serve both token families
2416
+ *
2417
+ * The contract has one `verifyAccessToken` and two rules of §4.3: the delegated
2418
+ * read passes `{ requiredScopes: ['apps:read'], maxLifetimeSeconds: 3_600 }`
2419
+ * (FR-44/FR-45), the exchange passes
2420
+ * `{ requiredScopes: ['ever-works:session'], maxLifetimeSeconds: 3_600, maxAgeSeconds: 300, allowedAuthorizedParties: <the configured local client ids> }`
2421
+ * (FR-40). Nothing here decides which family a token belongs to — that is the
2422
+ * caller's, and it is a decision about *why* the token is being presented, not
2423
+ * about the token.
2424
+ *
2425
+ * ## What is deliberately left to the caller
2426
+ *
2427
+ * **The `jti` replay window** (FR-40/FR-33's 600 seconds). The parsed `jti` is
2428
+ * returned and never enforced: it needs a store with a unique index, which is
2429
+ * T13's `ever-id-replay.service.ts`, and a plugin that held that state in
2430
+ * process memory would enforce a different window per replica. Plan §4.1 says
2431
+ * the same thing from the contract side ("the caller keeps the 600-second replay
2432
+ * window, not the plugin").
2433
+ *
2434
+ * **Whether the subject is a connected identity of an active account** — FR-40's
2435
+ * last clause and FR-45's. That is a database question and it belongs to the
2436
+ * services that own those rows.
2437
+ *
2438
+ * A refusal never partially succeeds: no claim of a refused token is returned.
2439
+ */
2440
+ async verifyAccessToken(token, input) {
2441
+ const resolved = await this.resolveSettings();
2442
+ if (!resolved.ok) throw new IdentityTokenRejectedError("providerUnavailable");
2443
+ const settings = resolved.settings;
2444
+ const document = await this.discoveryForVerification(settings.issuerUrl);
2445
+ const jwksUri = nonEmpty(document.jwks_uri);
2446
+ if (jwksUri === void 0) throw new IdentityTokenRejectedError("providerUnavailable");
2447
+ const verification = await this.verifyProviderSignature(token, jwksUri);
2448
+ return accessTokenClaims(verification.payload, {
2449
+ issuer: document.issuer,
2450
+ // FR-2's default when an administrator configured no allow-list: the one
2451
+ // issuer this installation accepts is its own.
2452
+ allowedIssuers: settings.allowedIssuers ?? [settings.issuerUrl],
2453
+ apiAudience: nonEmpty(settings.apiAudience) ?? OIDC_DEFAULT_API_AUDIENCE,
2454
+ requiredScopes: input.requiredScopes,
2455
+ maxLifetimeSeconds: input.maxLifetimeSeconds,
2456
+ maxAgeSeconds: input.maxAgeSeconds,
2457
+ allowedAuthorizedParties: input.allowedAuthorizedParties,
2458
+ skewSeconds: settings.clockSkewSeconds ?? OIDC_DEFAULT_CLOCK_SKEW_SECONDS,
2459
+ nowSeconds: Math.floor(this.now() / 1e3)
2460
+ });
2461
+ }
2462
+ /**
2463
+ * FR-33 — verify a back-channel logout notice and answer the three claims the
2464
+ * session teardown keys on.
2465
+ *
2466
+ * Validated exactly like an ID token (FR-33's "validated like FR-11 and FR-13":
2467
+ * the algorithm allow-list, the signature against Ever ID's published keys,
2468
+ * FR-13's ladder, FR-11's issuer rule, and the times), plus FR-33's own five:
2469
+ *
2470
+ * - the **back-channel logout event** is present ({@link OIDC_BACKCHANNEL_LOGOUT_EVENT});
2471
+ * - **no `nonce`** — the claim's mere presence is refused
2472
+ * (`nonceInLogoutToken`), because a notice is not an authentication and a
2473
+ * token that can be replayed as one is a token that should never verify;
2474
+ * - **`sid` or `sub`**, at least one, or there is no session to end
2475
+ * (`badLogoutEvent`);
2476
+ * - **`jti` required** (`badLogoutEvent`) — without it the 600-second replay
2477
+ * window has no key, and a notice that cannot be bounded must be refused
2478
+ * rather than treated as new;
2479
+ * - **`iat` within the skew and no older than 300 seconds** (`notYetValid`,
2480
+ * `tooOld`).
2481
+ *
2482
+ * ## `exp`, and the one place a logout token differs from an ID token
2483
+ *
2484
+ * Plan §4.3's ID-token row requires a readable `exp`; FR-33's logout-token row
2485
+ * does not mention one, and OpenID Connect Back-Channel Logout 1.0 §2.4 does not
2486
+ * put `exp` in the claim set a logout token **must** carry (`iss`, `aud`, `iat`,
2487
+ * `jti`, `events`). FR-53 makes that difference load-bearing: a provider that
2488
+ * publishes a standards-compliant notice would have every sign-out refused as
2489
+ * `expired` if this method demanded an `exp` the provider never promised, and a
2490
+ * refused sign-out is a session that stays open. So `exp` is **checked when it
2491
+ * is there** and not required, and the freshness bound FR-33 does state — `iat`
2492
+ * within the skew and no older than 300 seconds — is enforced either way. This
2493
+ * is a deliberate reading and is pinned by a case in
2494
+ * `src/__tests__/logout-token.spec.ts` in both directions.
2495
+ *
2496
+ * ## What is deliberately left to the caller
2497
+ *
2498
+ * The `jti` **replay window** (FR-33's 600 seconds; see
2499
+ * {@link verifyAccessToken}) and everything that happens to a session once this
2500
+ * method has answered — FR-34's "ends the sessions carrying it", FR-35's "never
2501
+ * ends a session opened by another method" and the 5-second bound are the
2502
+ * API's, not a token verifier's.
2503
+ */
2504
+ async verifyLogoutToken(token) {
2505
+ const resolved = await this.resolveSettings();
2506
+ if (!resolved.ok) throw new IdentityTokenRejectedError("providerUnavailable");
2507
+ const settings = resolved.settings;
2508
+ const document = await this.discoveryForVerification(settings.issuerUrl);
2509
+ const jwksUri = nonEmpty(document.jwks_uri);
2510
+ if (jwksUri === void 0) throw new IdentityTokenRejectedError("providerUnavailable");
2511
+ const verification = await this.verifyProviderSignature(token, jwksUri);
2512
+ return logoutTokenClaims(verification.payload, {
2513
+ issuer: document.issuer,
2514
+ allowedIssuers: settings.allowedIssuers ?? [settings.issuerUrl],
2515
+ skewSeconds: settings.clockSkewSeconds ?? OIDC_DEFAULT_CLOCK_SKEW_SECONDS,
2516
+ nowSeconds: Math.floor(this.now() / 1e3)
2517
+ });
2518
+ }
2519
+ /**
2520
+ * FR-36 — the address that ends the person's Ever ID session, or **`null`** when
2521
+ * the provider publishes no end-session endpoint.
2522
+ *
2523
+ * The `null` is the one answer in this plugin that is neither a set of claims
2524
+ * nor a refusal, and it means one thing only: the discovery document the plugin
2525
+ * already read has no `end_session_endpoint`, so there is nowhere to send the
2526
+ * person and the caller renders "sign out of Ever Works" alone. It is **not**
2527
+ * an error path — inventing a URL, or answering the issuer's address and hoping,
2528
+ * would send a browser somewhere the provider never agreed to serve.
2529
+ *
2530
+ * What is sent, and what is not:
2531
+ *
2532
+ * - `client_id` — how the provider knows which relying party is asking. The
2533
+ * alternative identifier, `id_token_hint`, is deliberately **not** sent:
2534
+ * FR-31 forbids this platform from storing an Ever ID token, so there is no
2535
+ * ID token to hint with, and OpenID Connect RP-Initiated Logout 1.0 names
2536
+ * `client_id` as the parameter to use in exactly that case.
2537
+ * - `post_logout_redirect_uri` — copied **byte for byte**, the same rule
2538
+ * FR-10 puts on the authorization request's `redirect_uri` and for the same
2539
+ * reason: the address an administrator registered is the address that
2540
+ * travels, never one this package rebuilt.
2541
+ * - `state` — carried verbatim. "Validated" in FR-36 is about the value the
2542
+ * API sealed into the sign-out transaction (plan §3.4/§3.5, T13's seal
2543
+ * service) being the value that comes back; the plugin neither mints nor
2544
+ * checks it, and must not, because it holds no per-session state.
2545
+ *
2546
+ * Errors are {@link OidcProviderUnavailableError} — T6's availability
2547
+ * vocabulary, exactly as in {@link buildAuthorizationRequest} — rather than an
2548
+ * `IdentityTokenRejectedError`: nothing here verifies a token, and a caller
2549
+ * needs to know which configuration or document problem stopped the sign-out:
2550
+ * `notConfigured`, `discoveryFailed`, `issuerDrift`, or `discoveryIncomplete`
2551
+ * (an `end_session_endpoint` that is published but is not a URL).
2552
+ */
2553
+ async buildEndSessionUrl(input) {
2554
+ const resolved = await this.resolveSettings();
2555
+ if (!resolved.ok) throw new OidcProviderUnavailableError("notConfigured");
2556
+ const settings = resolved.settings;
2557
+ const document = await this.readerFor(settings.issuerUrl).get();
2558
+ const endpoint = nonEmpty(document.end_session_endpoint);
2559
+ if (endpoint === void 0) return null;
2560
+ let url;
2561
+ try {
2562
+ url = new URL(endpoint);
2563
+ } catch {
2564
+ throw new OidcProviderUnavailableError("discoveryIncomplete");
2565
+ }
2566
+ url.searchParams.set("client_id", settings.clientId);
2567
+ url.searchParams.set("post_logout_redirect_uri", input.postLogoutRedirectUri);
2568
+ url.searchParams.set("state", input.state);
2569
+ return url.toString();
2570
+ }
2571
+ /**
2572
+ * Plan §9.2's plugin health view, in the platform's own lifecycle shape
2573
+ * (`PluginHealthCheck`, `packages/plugin/src/contracts/lifecycle.types.ts:24`).
2574
+ *
2575
+ * The three states, and why each one is the honest answer:
2576
+ *
2577
+ * - **`unhealthy`** — an administrator's configuration could not be read (a
2578
+ * required setting is missing or blank, or `resolveSettings` refused a set
2579
+ * value: a non-TLS issuer outside development, or a `clockSkewSeconds`
2580
+ * outside FR-2's bound — the `configuration` row names the field, never the
2581
+ * value), or FR-14/FR-5's availability record
2582
+ * says the provider was last seen unusable. The second is a real outage of
2583
+ * this capability: sign-in is off until a **Test connection** passes.
2584
+ * - **`healthy`** — the settings resolve and this process has successfully
2585
+ * read the discovery document or the key set.
2586
+ * - **`unknown`** — the settings resolve but nothing has been read yet. A
2587
+ * plugin that has not been called is not a broken plugin, and reporting
2588
+ * `unhealthy` for it would make every installation that does not use Ever ID
2589
+ * render a red health row (the plugin ships `autoEnable: false`).
2590
+ *
2591
+ * Everything it answers is a **name, a closed code or a number** (FR-16): the
2592
+ * check ids are this file's own, the unavailability reason is
2593
+ * {@link OidcProviderUnavailableReason}'s closed set, the timestamps are epoch
2594
+ * milliseconds, and no message carries the issuer, the client id, the client
2595
+ * secret or any part of a token. `duration` is measured on the injected clock
2596
+ * around the one await this method performs, so a caller (and a spec) can see
2597
+ * how long the settings read took.
2598
+ */
2599
+ async healthCheck() {
2600
+ const startedAt = this.now();
2601
+ const resolved = await this.resolveSettings();
2602
+ const configured = resolved.ok;
2603
+ const discoveryRefreshedAt = this.discoveryReader?.lastRefreshedAt ?? null;
2604
+ const jwksRefreshedAt = this.jwksRefreshedAtMs;
2605
+ const unavailable = configured && this.unavailableSinceMs !== null;
2606
+ const overall = !configured || unavailable ? "unhealthy" : discoveryRefreshedAt !== null || jwksRefreshedAt !== null ? "healthy" : "unknown";
2607
+ const checks = [
2608
+ {
2609
+ name: "configuration",
2610
+ status: configured ? "healthy" : "unhealthy",
2611
+ // The refused fields by NAME, exactly as `testConnection` reports them:
2612
+ // `resolveSettings` also refuses a set issuer (the non-TLS gate) and a set
2613
+ // `clockSkewSeconds` (the FR-2 bound), and a fixed "issuer, client id or
2614
+ // client secret is missing" would send the operator to the wrong field.
2615
+ message: resolved.ok ? "The issuer, client id and client secret are set." : `Not configured: ${resolved.missing.join(", ")}.`
2616
+ },
2617
+ {
2618
+ name: "availability",
2619
+ status: unavailable ? "unhealthy" : configured ? "healthy" : "unknown",
2620
+ message: unavailable ? `The provider was last seen unusable (${this.unavailableReason ?? "discoveryFailed"}); sign-in stays off until Test connection passes.` : "No unusable run has been recorded.",
2621
+ data: {
2622
+ unavailableSince: this.unavailableSinceMs === null ? null : new Date(this.unavailableSinceMs).toISOString(),
2623
+ unavailableReason: this.unavailableReason,
2624
+ discoveryCacheSeconds: OIDC_DISCOVERY_CACHE_SECONDS
2625
+ }
2626
+ },
2627
+ {
2628
+ name: "discovery",
2629
+ status: discoveryRefreshedAt === null ? "unknown" : "healthy",
2630
+ message: discoveryRefreshedAt === null ? "The discovery document has not been read by this process yet." : "The discovery document has been read by this process.",
2631
+ data: {
2632
+ refreshedAt: discoveryRefreshedAt === null ? null : new Date(discoveryRefreshedAt).toISOString(),
2633
+ cacheSeconds: OIDC_DISCOVERY_CACHE_SECONDS
2634
+ }
2635
+ },
2636
+ {
2637
+ name: "keys",
2638
+ status: jwksRefreshedAt === null ? "unknown" : "healthy",
2639
+ message: jwksRefreshedAt === null ? "No signing key set has been fetched by this process yet." : "A signing key set has been fetched by this process.",
2640
+ data: {
2641
+ refreshedAt: jwksRefreshedAt === null ? null : new Date(jwksRefreshedAt).toISOString(),
2642
+ cacheSeconds: OIDC_JWKS_CACHE_SECONDS,
2643
+ maxStaleSeconds: OIDC_JWKS_MAX_STALE_SECONDS
2644
+ }
2645
+ }
2646
+ ];
2647
+ return {
2648
+ status: overall,
2649
+ message: healthMessage(overall, this.unavailableReason),
2650
+ checks,
2651
+ checkedAt: this.now(),
2652
+ duration: this.now() - startedAt
2653
+ };
2654
+ }
2655
+ /**
2656
+ * The in-process availability record plan §4.2 stores under `availability`.
2657
+ *
2658
+ * Additive surface for T32, which persists these into the plugin's settings row so
2659
+ * every replica agrees within 60 seconds (FR-5): this is the same record one
2660
+ * process holds, exposed so the persistence task does not have to re-derive when a
2661
+ * run passed.
2662
+ *
2663
+ * `jwksRefreshedAt` is T7's addition — plan §9.2's health view has carried the
2664
+ * field since T5 wrote it into the settings schema
2665
+ * (`settings.schema.ts:262`) and nothing produced it until the key cache was
2666
+ * wired to the flow; it is `null` until a key set has been fetched, which is
2667
+ * exactly what plan §9.2's admin surface renders as "unknown".
2668
+ */
2669
+ getAvailability() {
2670
+ const refreshedAt = this.discoveryReader?.lastRefreshedAt ?? null;
2671
+ return {
2672
+ unavailableSince: this.unavailableSinceMs === null ? null : new Date(this.unavailableSinceMs).toISOString(),
2673
+ unavailableReason: this.unavailableReason,
2674
+ discoveryRefreshedAt: refreshedAt === null ? null : new Date(refreshedAt).toISOString(),
2675
+ jwksRefreshedAt: this.jwksRefreshedAtMs === null ? null : new Date(this.jwksRefreshedAtMs).toISOString(),
2676
+ discoveryCacheSeconds: OIDC_DISCOVERY_CACHE_SECONDS
2677
+ };
2678
+ }
2679
+ /**
2680
+ * The settings this plugin reads, or the field names that are missing.
2681
+ *
2682
+ * `context.getSettings()` resolves the admin-tier values, secrets included
2683
+ * (`SettingsSchemaValidatorService`, plan §4.2); a throw from the platform is
2684
+ * treated exactly like an absent configuration, because the fail-closed answer is
2685
+ * the same and neither path may surface the platform's own error text (FR-16).
2686
+ */
2687
+ async resolveSettings() {
2688
+ let raw = {};
2689
+ try {
2690
+ raw = await this.context?.getSettings() ?? {};
2691
+ } catch {
2692
+ raw = {};
2693
+ }
2694
+ const missing = REQUIRED_SETTING_KEYS.filter(
2695
+ (key) => typeof raw[key] !== "string" || raw[key].trim() === ""
2696
+ );
2697
+ if (missing.length > 0) return { ok: false, missing: [...missing] };
2698
+ if (isInsecureIssuer(raw.issuerUrl ?? "") && !allowsInsecureIssuer(this.nodeEnv)) {
2699
+ this.context?.logger?.error?.(
2700
+ "OpenID Connect identity (Ever ID): `issuerUrl` is a non-TLS (http://) address and NODE_ENV is production. Refusing the integration \u2014 an http issuer would send the ID token, the client secret and the key set in clear text. Configure an https issuer."
2701
+ );
2702
+ return { ok: false, missing: ["issuerUrl"] };
2703
+ }
2704
+ if (!isClockSkewInBounds(raw.clockSkewSeconds)) {
2705
+ this.context?.logger?.error?.(
2706
+ `OpenID Connect identity (Ever ID): \`clockSkewSeconds\` is not a whole number of seconds from 0 to ${OIDC_MAX_CLOCK_SKEW_SECONDS} (FR-2). Refusing the integration \u2014 a tolerance outside that bound would widen or disable every token time check. Set EVER_ID_CLOCK_SKEW_SECONDS inside the bound, or unset it to use the default.`
2707
+ );
2708
+ return { ok: false, missing: ["clockSkewSeconds"] };
2709
+ }
2710
+ return { ok: true, settings: raw };
2711
+ }
2712
+ /** One reader per configured issuer: a settings change starts a fresh cache (FR-14). */
2713
+ readerFor(issuerUrl) {
2714
+ if (this.discoveryReader === void 0 || this.discoveryIssuerUrl !== issuerUrl) {
2715
+ this.discoveryReader = new OidcDiscoveryReader({ issuerUrl, fetchImpl: this.fetchImpl, now: this.now });
2716
+ this.discoveryIssuerUrl = issuerUrl;
2717
+ }
2718
+ return this.discoveryReader;
2719
+ }
2720
+ /**
2721
+ * The discovery document the exchange and T8's two verifiers run against, with
2722
+ * T6's availability errors translated to the one refusal type their callers
2723
+ * branch on (see {@link exchangeAuthorizationCode}'s error contract).
2724
+ *
2725
+ * Named for the verification path rather than the exchange since T8: the read is
2726
+ * the same one FR-14's cache and issuer-drift refusal describe, and an access
2727
+ * token or a sign-out notice needs the same document for the same reason — the
2728
+ * `jwks_uri` and the issuer its claims are compared against.
2729
+ */
2730
+ async discoveryForVerification(issuerUrl) {
2731
+ try {
2732
+ return await this.readerFor(issuerUrl).get();
2733
+ } catch (error) {
2734
+ if (error instanceof OidcProviderUnavailableError) {
2735
+ throw new IdentityTokenRejectedError("providerUnavailable");
2736
+ }
2737
+ throw error;
2738
+ }
2739
+ }
2740
+ /**
2741
+ * The one POST of the sign-in flow (FR-15, FR-19).
2742
+ *
2743
+ * `client_secret_basic` is plan §4.2's authentication method for this provider,
2744
+ * and `ClientSecretBasic` from `openid-client` is what produces the header —
2745
+ * the same function the library's own grant calls use, so the encoding of the
2746
+ * credentials (`application/x-www-form-urlencoded` then base64, RFC 6749
2747
+ * §2.3.1) is not re-implemented here. `client_id` is therefore **not** in the
2748
+ * body: RFC 6749 §4.1.3 requires it only "if the client is not authenticating
2749
+ * with the authorization server", and sending it twice is a habit providers
2750
+ * tolerate rather than a requirement they state.
2751
+ *
2752
+ * The response is read as JSON and the `id_token` taken from it. Every way the
2753
+ * call can fail is one code — `providerUnavailable` — because the closed
2754
+ * vocabulary has no code for "the provider refused the grant" and a precise
2755
+ * code with the wrong meaning is worse than a coarse one that is right (see the
2756
+ * report for T4/T25: an `invalid_grant` therefore reaches the API as 503 today).
2757
+ */
2758
+ async redeemAuthorizationCode(input, settings, tokenEndpoint) {
2759
+ const body = new URLSearchParams({
2760
+ grant_type: "authorization_code",
2761
+ code: input.code,
2762
+ redirect_uri: input.redirectUri,
2763
+ code_verifier: input.codeVerifier
2764
+ });
2765
+ const headers = new Headers();
2766
+ ClientSecretBasic(settings.clientSecret)(
2767
+ { issuer: settings.issuerUrl, token_endpoint: tokenEndpoint },
2768
+ { client_id: settings.clientId, token_endpoint_auth_method: "client_secret_basic" },
2769
+ body,
2770
+ headers
2771
+ );
2772
+ const result = await fetchJsonOnce(
2773
+ {
2774
+ url: tokenEndpoint,
2775
+ method: "POST",
2776
+ body: body.toString(),
2777
+ contentType: "application/x-www-form-urlencoded",
2778
+ headers: { authorization: headers.get("authorization") ?? "" }
2779
+ },
2780
+ { fetchImpl: this.fetchImpl, now: this.now }
2781
+ );
2782
+ if (!result.ok) throw new IdentityTokenRejectedError("providerUnavailable");
2783
+ const idToken = idTokenOf(result.body);
2784
+ if (idToken === null) throw new IdentityTokenRejectedError("providerUnavailable");
2785
+ return idToken;
2786
+ }
2787
+ /**
2788
+ * FR-11's first two clauses, through T6's key cache: the algorithm allow-list
2789
+ * and the signature, with the cache's own ladder (600 s, 30 s, 21,600 s) and
2790
+ * its `providerUnavailable` end.
2791
+ *
2792
+ * **One implementation for all three token kinds.** T7 wrote it for the ID token
2793
+ * and named it for one; T8's `verifyAccessToken` and `verifyLogoutToken` call the
2794
+ * same method, because "the algorithm is one of RS256/ES256/EdDSA and the
2795
+ * signature verifies against a key Ever ID published, inside FR-13's ladder" is
2796
+ * one rule and FR-45/FR-33 both adopt it by reference ("validated like FR-11 and
2797
+ * FR-13"). A second copy would be a second place for the ladder to drift.
2798
+ *
2799
+ * Both of T6's error types become `IdentityTokenRejectedError`: `badAlg` and
2800
+ * `badSignature` keep their code, and anything the cache could not answer
2801
+ * because the provider is unreachable or its keys are stale is
2802
+ * `providerUnavailable`. An error that is neither is a bug in this package
2803
+ * rather than a verdict about a token, so it propagates instead of being
2804
+ * disguised as a provider outage — no claims are returned either way, which is
2805
+ * what fail-closed means here.
2806
+ */
2807
+ async verifyProviderSignature(compactJws, jwksUri) {
2808
+ try {
2809
+ return await this.jwksFor(jwksUri).verify(compactJws);
2810
+ } catch (error) {
2811
+ if (error instanceof OidcJwksVerificationError) throw new IdentityTokenRejectedError(error.code);
2812
+ if (error instanceof OidcProviderUnavailableError) {
2813
+ throw new IdentityTokenRejectedError("providerUnavailable");
2814
+ }
2815
+ throw error;
2816
+ }
2817
+ }
2818
+ /** One key cache per resolved `jwks_uri`; a provider that moves its keys starts a fresh one (FR-13). */
2819
+ jwksFor(jwksUri) {
2820
+ if (this.jwksCache === void 0 || this.jwksUri !== jwksUri) {
2821
+ this.jwksCache = new OidcJwksCache({
2822
+ jwksUri,
2823
+ fetchImpl: this.fetchImpl,
2824
+ now: this.now,
2825
+ // Plan §9.2's `jwksRefreshedAt`, and nothing else: a *failed* fetch
2826
+ // does not move it, which is what makes FR-13's staleness window
2827
+ // readable from outside the cache.
2828
+ onRefreshed: (refreshedAt) => {
2829
+ this.jwksRefreshedAtMs = refreshedAt;
2830
+ }
2831
+ });
2832
+ this.jwksUri = jwksUri;
2833
+ }
2834
+ return this.jwksCache;
2835
+ }
2836
+ /**
2837
+ * The `openid-client` configuration for one document and one resolved settings
2838
+ * object — used for `buildAuthorizationUrl`, which is a pure URL builder (no
2839
+ * socket, no timer), and for nothing else.
2840
+ *
2841
+ * The rest of the flow does not go through `openid-client`'s `authorizationCodeGrant`,
2842
+ * and that is a measured decision rather than an omission: that call validates
2843
+ * the ID token itself against its own remote key set, which caches for 600
2844
+ * seconds and **keeps using a stale set indefinitely** — the opposite of FR-13's
2845
+ * 21,600-second fail-closed end that `jwks-cache.ts` exists to enforce — and it
2846
+ * applies its own `clockTolerance` to the time claims, which would decide
2847
+ * FR-11's skew edges instead of the administrator's configured
2848
+ * `clockSkewSeconds`. Plan §4.2 names the library for this flow; where its
2849
+ * built-in policy contradicts the spec, the spec's own module wins and the
2850
+ * library keeps the parts that are exactly right (`buildAuthorizationUrl`,
2851
+ * `calculatePKCECodeChallenge`, `ClientSecretBasic`) — the same trade T6
2852
+ * documented for `discovery()`.
2853
+ */
2854
+ configurationFor(document, settings) {
2855
+ const configuration = new Configuration(
2856
+ // The document was read from the wire, so the cast is the same one
2857
+ // `asDiscoveryDocument` makes on the way in: the fields this flow reads are
2858
+ // the ones typed above, and every other claim is passed through as published.
2859
+ document,
2860
+ settings.clientId,
2861
+ settings.clientSecret,
2862
+ // Plan §4.2's `client_secret_basic`. Passing the secret as metadata would
2863
+ // select `openid-client`'s default, which is `client_secret_post` — a
2864
+ // different authentication method than the one this installation registers.
2865
+ ClientSecretBasic(settings.clientSecret)
2866
+ );
2867
+ if (isInsecureIssuer(settings.issuerUrl)) {
2868
+ if (!allowsInsecureIssuer(this.nodeEnv)) {
2869
+ throw new OidcProviderUnavailableError("notConfigured");
2870
+ }
2871
+ allowInsecureRequests(configuration);
2872
+ }
2873
+ return configuration;
2874
+ }
2875
+ /** Exactly `length` random bytes, from the injected seam or the platform CSPRNG (FR-9). */
2876
+ random(length) {
2877
+ return (this.randomBytes ?? defaultRandomBytes)(length);
2878
+ }
2879
+ /** Set or clear plan §4.2's `unavailableSince` from the five checks that gate it. */
2880
+ applyAvailability(checks) {
2881
+ const failed = checks.find((check) => FR3_REQUIRED_CHECK_IDS.includes(check.id) && !check.ok);
2882
+ if (failed === void 0) {
2883
+ this.unavailableSinceMs = null;
2884
+ this.unavailableReason = null;
2885
+ return;
2886
+ }
2887
+ this.recordUnavailable(failed.id === "issuerMatch" ? "issuerDrift" : "discoveryFailed");
2888
+ }
2889
+ /** FR-14/FR-5: the provider is off from the first failing run until a passing one. */
2890
+ recordUnavailable(reason) {
2891
+ if (this.unavailableSinceMs === null) this.unavailableSinceMs = this.now();
2892
+ this.unavailableReason = reason;
2893
+ }
2894
+ /**
2895
+ * One log line per run, and it carries counts and check **ids** only.
2896
+ *
2897
+ * Ids come from the closed contract set, never from the provider, so nothing a
2898
+ * provider chose to send can reach a log through this path (FR-16).
2899
+ */
2900
+ logChecks(checks) {
2901
+ const passed = checks.filter((check) => check.ok).length;
2902
+ const failing = checks.filter((check) => !check.ok).map((check) => check.id);
2903
+ const suffix = failing.length === 0 ? "" : ` \u2014 failing: ${failing.join(", ")}`;
2904
+ this.context?.logger.log(`Test connection: ${passed}/${checks.length} checks passed${suffix}`);
2905
+ }
2906
+ getManifest() {
2907
+ return {
2908
+ id: this.id,
2909
+ name: this.name,
2910
+ version: this.version,
2911
+ description: "OpenID Connect relying party for Ever ID: browser sign-in, connected identities, delegated reads and back-channel logout",
2912
+ category: this.category,
2913
+ capabilities: [...this.capabilities],
2914
+ author: { name: "Ever Works Team" },
2915
+ license: "AGPL-3.0",
2916
+ builtIn: true,
2917
+ // The installation opts in explicitly: discovery lists it disabled (FR-5's
2918
+ // kill switch is the plugin toggle, plan §9.2).
2919
+ autoEnable: false
2920
+ };
2921
+ }
2922
+ };
2923
+ var oidc_identity_plugin_default = OidcIdentityPlugin;
2924
+ var REQUIRED_SETTING_KEYS = ["issuerUrl", "clientId", "clientSecret"];
2925
+ var defaultRandomBytes = (length) => new Uint8Array(randomBytes(length));
2926
+ function encodeBase64Url(bytes) {
2927
+ return Buffer.from(bytes).toString("base64url");
2928
+ }
2929
+ var NON_PRODUCTION_NODE_ENVS = /* @__PURE__ */ new Set(["development", "test"]);
2930
+ function allowsInsecureIssuer(nodeEnv) {
2931
+ return NON_PRODUCTION_NODE_ENVS.has((nodeEnv ?? process.env.NODE_ENV ?? "").trim().toLowerCase());
2932
+ }
2933
+ function isInsecureIssuer(issuerUrl) {
2934
+ try {
2935
+ return new URL(issuerUrl).protocol === "http:";
2936
+ } catch {
2937
+ return false;
2938
+ }
2939
+ }
2940
+ function isClockSkewInBounds(value) {
2941
+ if (value === void 0 || value === null) return true;
2942
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= OIDC_MAX_CLOCK_SKEW_SECONDS;
2943
+ }
2944
+ function idTokenOf(body) {
2945
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
2946
+ return nonEmpty(body.id_token) ?? null;
2947
+ }
2948
+ function idTokenClaims(payload, rules) {
2949
+ const issuer = trustedIssuer(payload, rules);
2950
+ const audiences = audiencesOf(payload.aud);
2951
+ if (audiences === null || !audiences.includes(rules.clientId)) {
2952
+ throw new IdentityTokenRejectedError("badAudience");
2953
+ }
2954
+ if (audiences.length > 1 && payload.azp !== rules.clientId) {
2955
+ throw new IdentityTokenRejectedError("badAuthorizedParty");
2956
+ }
2957
+ tokenTimes(payload, rules, OIDC_ID_TOKEN_MAX_AGE_SECONDS);
2958
+ if (payload.nonce !== rules.expectedNonce) throw new IdentityTokenRejectedError("badNonce");
2959
+ const subject = subjectOf(payload);
2960
+ const authTime = secondsClaim(payload.auth_time);
2961
+ if (rules.maxAuthAgeSeconds !== void 0) {
2962
+ if (authTime === null || authTime < rules.nowSeconds - rules.maxAuthAgeSeconds - rules.skewSeconds) {
2963
+ throw new IdentityTokenRejectedError("tooOld");
2964
+ }
2965
+ }
2966
+ const claims = {
2967
+ issuer,
2968
+ subject,
2969
+ // FR-23/FR-24/FR-25 branch on the pair (`email`, `email_verified`), and the
2970
+ // contract's "absent or unusable is `null`" is what makes `email: null` and
2971
+ // `emailVerified: false` two different states rather than one.
2972
+ email: nonEmpty(payload.email) ?? null,
2973
+ emailVerified: payload.email_verified === true,
2974
+ name: nonEmpty(payload.name) ?? null,
2975
+ authTime,
2976
+ sid: nonEmpty(payload.sid) ?? null
2977
+ };
2978
+ const hints = everHintClaims(payload);
2979
+ if (hints !== void 0) claims.hints = hints;
2980
+ return claims;
2981
+ }
2982
+ var OIDC_HINT_CLAIM_PREFIX = "urn:ever:";
2983
+ var OIDC_HINT_CLAIMS_MAX = 32;
2984
+ function everHintClaims(payload) {
2985
+ const out = {};
2986
+ let count = 0;
2987
+ for (const [key, value] of Object.entries(payload)) {
2988
+ if (!key.startsWith(OIDC_HINT_CLAIM_PREFIX)) continue;
2989
+ if (count >= OIDC_HINT_CLAIMS_MAX) break;
2990
+ out[key] = value;
2991
+ count += 1;
2992
+ }
2993
+ return count === 0 ? void 0 : Object.freeze(out);
2994
+ }
2995
+ function trustedIssuer(payload, rules) {
2996
+ const issuer = payload.iss;
2997
+ if (typeof issuer !== "string" || issuer !== rules.issuer || !rules.allowedIssuers.includes(issuer)) {
2998
+ throw new IdentityTokenRejectedError("badIssuer");
2999
+ }
3000
+ return issuer;
3001
+ }
3002
+ function assertNotExpired(payload, rules) {
3003
+ const expiresAt = secondsClaim(payload.exp);
3004
+ if (expiresAt !== null && expiresAt <= rules.nowSeconds - rules.skewSeconds) {
3005
+ throw new IdentityTokenRejectedError("expired");
3006
+ }
3007
+ return expiresAt;
3008
+ }
3009
+ function assertIssuedAt(payload, rules, maxAgeSeconds) {
3010
+ const issuedAt = secondsClaim(payload.iat);
3011
+ if (issuedAt === null) throw new IdentityTokenRejectedError("tooOld");
3012
+ if (issuedAt > rules.nowSeconds + rules.skewSeconds) throw new IdentityTokenRejectedError("notYetValid");
3013
+ if (maxAgeSeconds !== void 0 && issuedAt < rules.nowSeconds - maxAgeSeconds) {
3014
+ throw new IdentityTokenRejectedError("tooOld");
3015
+ }
3016
+ return issuedAt;
3017
+ }
3018
+ function tokenTimes(payload, rules, maxAgeSeconds) {
3019
+ const expiresAt = assertNotExpired(payload, rules);
3020
+ if (expiresAt === null) throw new IdentityTokenRejectedError("expired");
3021
+ return { issuedAt: assertIssuedAt(payload, rules, maxAgeSeconds), expiresAt };
3022
+ }
3023
+ function subjectOf(payload) {
3024
+ const subject = payload.sub;
3025
+ if (typeof subject !== "string" || subject.length < 1 || subject.length > OIDC_SUBJECT_MAX_LENGTH) {
3026
+ throw new IdentityTokenRejectedError("badSignature");
3027
+ }
3028
+ return subject;
3029
+ }
3030
+ function scopesOf(value) {
3031
+ if (typeof value !== "string") return [];
3032
+ return [...new Set(value.split(/\s+/u).filter((scope) => scope.length > 0))];
3033
+ }
3034
+ function accessTokenClaims(payload, rules) {
3035
+ const issuer = trustedIssuer(payload, rules);
3036
+ const audiences = audiencesOf(payload.aud);
3037
+ if (audiences === null || !audiences.includes(rules.apiAudience)) {
3038
+ throw new IdentityTokenRejectedError("badAudience");
3039
+ }
3040
+ const scopes = scopesOf(payload.scope);
3041
+ if (rules.requiredScopes.some((scope) => !scopes.includes(scope))) {
3042
+ throw new IdentityTokenRejectedError("missingScope");
3043
+ }
3044
+ const { issuedAt, expiresAt } = tokenTimes(payload, rules, rules.maxAgeSeconds);
3045
+ if (expiresAt - issuedAt > rules.maxLifetimeSeconds) {
3046
+ throw new IdentityTokenRejectedError("lifetimeTooLong");
3047
+ }
3048
+ const authorizedParty = nonEmpty(payload.azp) ?? null;
3049
+ if (rules.allowedAuthorizedParties !== void 0 && (authorizedParty === null || !rules.allowedAuthorizedParties.includes(authorizedParty))) {
3050
+ throw new IdentityTokenRejectedError("badAuthorizedParty");
3051
+ }
3052
+ return {
3053
+ issuer,
3054
+ subject: subjectOf(payload),
3055
+ audience: audiences,
3056
+ scopes,
3057
+ authorizedParty,
3058
+ issuedAt,
3059
+ expiresAt,
3060
+ jti: nonEmpty(payload.jti) ?? null
3061
+ };
3062
+ }
3063
+ function logoutTokenClaims(payload, rules) {
3064
+ const issuer = trustedIssuer(payload, rules);
3065
+ assertNotExpired(payload, rules);
3066
+ assertIssuedAt(payload, rules, OIDC_LOGOUT_TOKEN_MAX_AGE_SECONDS);
3067
+ const events = payload.events;
3068
+ if (typeof events !== "object" || events === null || Array.isArray(events)) {
3069
+ throw new IdentityTokenRejectedError("badLogoutEvent");
3070
+ }
3071
+ if (!Object.prototype.hasOwnProperty.call(events, OIDC_BACKCHANNEL_LOGOUT_EVENT)) {
3072
+ throw new IdentityTokenRejectedError("badLogoutEvent");
3073
+ }
3074
+ if (payload.nonce !== void 0) throw new IdentityTokenRejectedError("nonceInLogoutToken");
3075
+ const subject = optionalSubjectOf(payload.sub);
3076
+ const sid = nonEmpty(payload.sid) ?? null;
3077
+ if (subject === null && sid === null) {
3078
+ throw new IdentityTokenRejectedError("badLogoutEvent");
3079
+ }
3080
+ const jti = nonEmpty(payload.jti);
3081
+ if (jti === void 0) throw new IdentityTokenRejectedError("badLogoutEvent");
3082
+ return { issuer, subject, sid, jti };
3083
+ }
3084
+ function optionalSubjectOf(value) {
3085
+ const subject = nonEmpty(value);
3086
+ if (subject === void 0) return null;
3087
+ return subject.length <= OIDC_SUBJECT_MAX_LENGTH ? subject : null;
3088
+ }
3089
+ function healthMessage(status, reason) {
3090
+ switch (status) {
3091
+ case "healthy":
3092
+ return "Ever ID is configured and this process has read the provider.";
3093
+ case "unhealthy":
3094
+ return `Ever ID is unavailable (${reason ?? "notConfigured"}); Test connection must pass before sign-in works again.`;
3095
+ case "degraded":
3096
+ return "Ever ID is degraded.";
3097
+ default:
3098
+ return "Ever ID has not been read by this process yet.";
3099
+ }
3100
+ }
3101
+ function audiencesOf(value) {
3102
+ if (typeof value === "string") return [value];
3103
+ if (!Array.isArray(value) || value.length === 0) return null;
3104
+ return value.every((entry) => typeof entry === "string") ? [...value] : null;
3105
+ }
3106
+ function secondsClaim(value) {
3107
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
3108
+ }
3109
+ function row(id, ok, detail) {
3110
+ if (ok) return { id, ok };
3111
+ return { id, ok, detail: detail ?? "The check failed." };
3112
+ }
3113
+ function unavailableRows(discoveryDetail, otherDetail) {
3114
+ return FR3_CHECK_IDS.map((id, index) => row(id, false, index === 0 ? discoveryDetail : otherDetail));
3115
+ }
3116
+ function checksFromDocument(document, issuerUrl) {
3117
+ const missingEndpoints = ["authorization_endpoint", "token_endpoint", "jwks_uri"].filter(
3118
+ (name) => typeof document[name] !== "string" || document[name].length === 0
3119
+ );
3120
+ const challengeMethods = Array.isArray(document.code_challenge_methods_supported) ? document.code_challenge_methods_supported : [];
3121
+ const signingAlgorithms = Array.isArray(document.id_token_signing_alg_values_supported) ? document.id_token_signing_alg_values_supported : [];
3122
+ return [
3123
+ row("discovery", true),
3124
+ // FR-3's word is "exactly": no trailing-slash normalisation, no case folding.
3125
+ // The allow-list (`allowedIssuers`, FR-2) is what makes a planned provider
3126
+ // move reversible, and it is checked on the token's `iss` (§4.3), not here.
3127
+ row(
3128
+ "issuerMatch",
3129
+ document.issuer === issuerUrl,
3130
+ "The issuer the provider advertises does not match the configured issuer exactly."
3131
+ ),
3132
+ row(
3133
+ "endpoints",
3134
+ missingEndpoints.length === 0,
3135
+ `The discovery document is missing ${missingEndpoints.join(", ")}.`
3136
+ ),
3137
+ row(
3138
+ "pkceS256",
3139
+ challengeMethods.includes("S256"),
3140
+ "The provider does not advertise S256 code challenge support."
3141
+ ),
3142
+ row(
3143
+ "signingAlg",
3144
+ OIDC_IDENTITY_SIGNING_ALGS.some((alg) => signingAlgorithms.includes(alg)),
3145
+ "The provider advertises none of RS256, ES256 or EdDSA."
3146
+ ),
3147
+ // FR-3 asks whether these two are supported; they are reported and never gate
3148
+ // availability (see FR3_REQUIRED_CHECK_IDS).
3149
+ row(
3150
+ "backchannelLogout",
3151
+ document.backchannel_logout_supported === true,
3152
+ "The provider does not advertise back-channel logout."
3153
+ ),
3154
+ row(
3155
+ "deviceAuthorization",
3156
+ typeof document.device_authorization_endpoint === "string" && document.device_authorization_endpoint.length > 0,
3157
+ "The provider does not advertise a device authorization endpoint."
3158
+ )
3159
+ ];
3160
+ }
3161
+ function discoveryFailureDetail(failure, status) {
3162
+ switch (failure) {
3163
+ case "timeout":
3164
+ return "The discovery document did not answer within 5 seconds.";
3165
+ case "httpStatus":
3166
+ return typeof status === "number" ? `The discovery document answered HTTP ${status}.` : "The discovery document answered an error status.";
3167
+ case "network":
3168
+ return "The discovery document could not be reached.";
3169
+ case "invalidBody":
3170
+ return "The discovery document is not JSON.";
3171
+ case "invalidDocument":
3172
+ return "The discovery document carries no issuer.";
3173
+ case "invalidIssuer":
3174
+ return "The configured issuer address is not a valid URL.";
3175
+ }
3176
+ }
3177
+ function nonEmpty(value) {
3178
+ if (typeof value !== "string") return void 0;
3179
+ const trimmed = value.trim();
3180
+ return trimmed === "" ? void 0 : trimmed;
3181
+ }
3182
+ function localClientsOf(value) {
3183
+ if (!Array.isArray(value)) return [];
3184
+ return value.flatMap((entry) => {
3185
+ if (typeof entry !== "object" || entry === null) return [];
3186
+ const { kind, clientId } = entry;
3187
+ if (kind !== "cli" && kind !== "node") return [];
3188
+ const id = nonEmpty(clientId);
3189
+ return id === void 0 ? [] : [{ kind, clientId: id }];
3190
+ });
3191
+ }
3192
+ export {
3193
+ OIDC_ACCESS_TOKEN_MAX_AGE_SECONDS,
3194
+ OIDC_ACCESS_TOKEN_MAX_LIFETIME_SECONDS,
3195
+ OIDC_BACKCHANNEL_LOGOUT_EVENT,
3196
+ OIDC_CODE_VERIFIER_BYTES,
3197
+ OIDC_CODE_VERIFIER_LENGTH,
3198
+ OIDC_DEFAULT_API_AUDIENCE,
3199
+ OIDC_DEFAULT_CLOCK_SKEW_SECONDS,
3200
+ OIDC_DELEGATED_READ_SCOPE,
3201
+ OIDC_DISCOVERY_CACHE_SECONDS,
3202
+ OIDC_IDENTITY_SETTING_KEYS,
3203
+ OIDC_IDENTITY_SIGNING_ALGS,
3204
+ OIDC_ID_TOKEN_MAX_AGE_SECONDS,
3205
+ OIDC_JWKS_CACHE_SECONDS,
3206
+ OIDC_JWKS_MAX_STALE_SECONDS,
3207
+ OIDC_JWKS_UNKNOWN_KID_COOLDOWN_SECONDS,
3208
+ OIDC_LOGOUT_TOKEN_MAX_AGE_SECONDS,
3209
+ OIDC_LOGOUT_TOKEN_REPLAY_WINDOW_SECONDS,
3210
+ OIDC_MAX_CLOCK_SKEW_SECONDS,
3211
+ OIDC_NEVER_REQUESTED_SCOPES,
3212
+ OIDC_NONCE_BYTES,
3213
+ OIDC_OUTBOUND_MAX_ATTEMPTS,
3214
+ OIDC_OUTBOUND_RETRY_DELAY_MS,
3215
+ OIDC_OUTBOUND_TIMEOUT_MS,
3216
+ OIDC_SESSION_EXCHANGE_SCOPE,
3217
+ OIDC_SIGN_IN_SCOPE,
3218
+ OIDC_SIGN_IN_SCOPES,
3219
+ OIDC_STATE_BYTES,
3220
+ OIDC_SUBJECT_MAX_LENGTH,
3221
+ OIDC_TOKEN_SCOPES,
3222
+ OidcDiscoveryReader,
3223
+ OidcIdentityPlugin,
3224
+ OidcJwksCache,
3225
+ OidcJwksVerificationError,
3226
+ OidcProviderUnavailableError,
3227
+ oidc_identity_plugin_default as default,
3228
+ fetchJsonOnce,
3229
+ isSignInScope,
3230
+ oidcIdentityHttpsUrlPattern,
3231
+ oidcIdentityIssuerUrlPattern,
3232
+ oidcIdentitySettingsSchema
3233
+ };
3234
+ /**
3235
+ * One header finding of the licence scan (plan §2.6:324-325, FR-54): the
3236
+ * `SPDX-License-Identifier:` / `@license` tag a file declares in its first
3237
+ * `LICENSE_HEADER_SCAN_BYTES` bytes.
3238
+ *
3239
+ * **Gap, named rather than guessed:** plan §3.1:437 writes the column's third
3240
+ * field as `headerFindings: …` without a shape. `path` is certain — it is the
3241
+ * first field of the scan's input — and the detected identifier is the second
3242
+ * fact the scan produces, so both are declared; anything further an
3243
+ * implementation adds is additive.
3244
+ */