@camstack/server 1.2.82 → 1.2.83

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.
@@ -9,6 +9,15 @@ const consent_page_js_1 = require("./consent-page.js");
9
9
  const private_host_js_1 = require("./private-host.js");
10
10
  /** Longest caller-supplied `integration` value echoed back in an error. */
11
11
  const MAX_ECHOED_INTEGRATION_LEN = 100;
12
+ /**
13
+ * `Retry-After` on a refusal caused by an addon that has not registered yet.
14
+ *
15
+ * A forked addon's runner spawns, loads and initialises in seconds. This is
16
+ * advice to the CLIENT and nothing more — the hub never sleeps, never polls
17
+ * and never queues the request ([D3](../../../../../docs/decisions/adr-0003.md));
18
+ * it answers immediately with what is true right now.
19
+ */
20
+ const INTEGRATION_PENDING_RETRY_AFTER_SEC = 5;
12
21
  /** Render the received `integration` for a human, bounded. */
13
22
  function describeReceivedIntegration(raw) {
14
23
  if (raw === undefined)
@@ -48,15 +57,16 @@ function resolveIntegrationParam(raw) {
48
57
  * NOT checked — that pair is verified only at the Lambda boundary, and a
49
58
  * PUBLIC client (`requiresPkce`) has no secret to check it against at all;
50
59
  * the S256 challenge is what binds the code to its requester instead. */
51
- function validateAuthorizeQuery(q, knownIntegrations) {
60
+ function validateAuthorizeQuery(q, knownIntegrations, pendingAddons = []) {
52
61
  if (q.response_type !== 'code')
53
62
  return { ok: false, status: 400, error: 'unsupported_response_type' };
54
- // Three distinct client faults used to collapse into one opaque string.
63
+ // Four distinct faults used to collapse into one opaque string.
55
64
  // Alexa linking was down for a day because the message named neither what
56
65
  // arrived nor what would have been accepted.
57
66
  const detail = {
58
67
  received: describeReceivedIntegration(q.integration),
59
68
  known_integrations: [...knownIntegrations.keys()],
69
+ ...(pendingAddons.length > 0 ? { pending_addons: pendingAddons } : {}),
60
70
  };
61
71
  const param = resolveIntegrationParam(q.integration);
62
72
  if (!param.ok) {
@@ -67,6 +77,21 @@ function validateAuthorizeQuery(q, knownIntegrations) {
67
77
  }
68
78
  const policy = knownIntegrations.get(param.value);
69
79
  if (!policy) {
80
+ // The fourth fault, and the one moving every descriptor into an addon made
81
+ // likelier: the id is absent because the addon that owns it has not
82
+ // registered yet. `invalid_request` means "your request is wrong, do not
83
+ // repeat it" — during boot that is a lie, and it is the lie that sends a
84
+ // client to its degraded fallback and leaves it there. `temporarily_
85
+ // unavailable` is RFC 6749 §4.1.2.1 and says the opposite.
86
+ if (pendingAddons.length > 0) {
87
+ return {
88
+ ok: false,
89
+ status: 503,
90
+ error: 'temporarily_unavailable — integration not registered yet',
91
+ detail,
92
+ retryAfterSec: INTEGRATION_PENDING_RETRY_AFTER_SEC,
93
+ };
94
+ }
70
95
  return { ok: false, status: 400, error: 'invalid_request — unknown integration', detail };
71
96
  }
72
97
  if (!q.redirect_uri)
@@ -138,19 +163,29 @@ function refuseAuthorize(reply, logger, method, refusal) {
138
163
  method,
139
164
  received: refusal.detail?.received ?? null,
140
165
  knownIntegrations: refusal.detail?.known_integrations ?? [],
166
+ // Without this, a refusal during the boot window is byte-identical in
167
+ // Loki to one against a hub that has been up for a week.
168
+ pendingAddons: refusal.detail?.pending_addons ?? [],
141
169
  },
142
170
  });
171
+ if (refusal.retryAfterSec !== undefined) {
172
+ void reply.header('Retry-After', String(refusal.retryAfterSec));
173
+ }
143
174
  return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
144
175
  }
145
- /** Build a map of integrationId → descriptor from all registered oauth-integration providers. */
146
- async function buildIntegrationMap(registry) {
176
+ /** Read integrationId → descriptor from every registered `oauth-integration`
177
+ * provider, plus the addons that still owe one. */
178
+ async function readIntegrationRegistry(registry) {
147
179
  const entries = registry.getCollectionEntries('oauth-integration');
148
- const descriptorMap = new Map();
180
+ const descriptors = new Map();
149
181
  for (const [, provider] of entries) {
150
182
  const descriptor = await provider.getDescriptor();
151
- descriptorMap.set(descriptor.integrationId, descriptor);
183
+ descriptors.set(descriptor.integrationId, descriptor);
152
184
  }
153
- return descriptorMap;
185
+ const pendingAddons = registry
186
+ .getManifestDeclarers('oauth-integration')
187
+ .filter((addonId) => !registry.hasProvider('oauth-integration', addonId));
188
+ return { descriptors, pendingAddons };
154
189
  }
155
190
  /** Parse an application/x-www-form-urlencoded body string into a plain object. */
156
191
  function parseFormBody(raw) {
@@ -199,13 +234,13 @@ function registerOauth2Routes(fastify, deps) {
199
234
  if (!registry) {
200
235
  return reply.status(503).send({ error: 'service_unavailable' });
201
236
  }
202
- const descriptorMap = await buildIntegrationMap(registry);
237
+ const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
203
238
  const query = request.query;
204
- const v = validateAuthorizeQuery(query, descriptorMap);
239
+ const v = validateAuthorizeQuery(query, descriptors, pendingAddons);
205
240
  if (!v.ok) {
206
241
  return refuseAuthorize(reply, deps.logger, 'GET', v);
207
242
  }
208
- const descriptor = descriptorMap.get(v.integration);
243
+ const descriptor = descriptors.get(v.integration);
209
244
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
210
245
  deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
211
246
  meta: {
@@ -247,18 +282,31 @@ function registerOauth2Routes(fastify, deps) {
247
282
  // from one that is refusing it, or the client dead-ends on a 400 with no way
248
283
  // to fall back. Unauthenticated (so is /token) and it discloses only which
249
284
  // integrations are installed.
285
+ //
286
+ // `complete` exists because absence-means-old-hub is a STICKY verdict: a
287
+ // client that probes 200ms after a restart sees a short list, concludes the
288
+ // hub cannot do OAuth, and settles into its password fallback for good. The
289
+ // flag says whether the list is final; a client SHOULD re-probe while it is
290
+ // false rather than downgrade. The pending addon IDS are deliberately not
291
+ // here — this route needs no token, and "not final yet" is the entire signal
292
+ // a client can act on. They are on the session-gated /authorize refusal,
293
+ // where a human is reading them as a diagnosis.
250
294
  fastify.get('/api/oauth2/integrations', async (_request, reply) => {
251
295
  const registry = deps.getRegistry();
252
296
  if (!registry) {
253
297
  return reply.status(503).send({ error: 'service_unavailable' });
254
298
  }
255
- const descriptorMap = await buildIntegrationMap(registry);
299
+ const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
300
+ if (pendingAddons.length > 0) {
301
+ void reply.header('Retry-After', String(INTEGRATION_PENDING_RETRY_AFTER_SEC));
302
+ }
256
303
  return reply.send({
257
- integrations: [...descriptorMap.values()].map((d) => ({
304
+ integrations: [...descriptors.values()].map((d) => ({
258
305
  integrationId: d.integrationId,
259
306
  displayName: d.displayName,
260
307
  requiresPkce: d.requiresPkce === true,
261
308
  })),
309
+ complete: pendingAddons.length === 0,
262
310
  });
263
311
  });
264
312
  // ─── POST /api/oauth2/authorize ───────────────────────────────────────────
@@ -284,7 +332,7 @@ function registerOauth2Routes(fastify, deps) {
284
332
  if (!registry) {
285
333
  return reply.status(503).send({ error: 'service_unavailable' });
286
334
  }
287
- const descriptorMap = await buildIntegrationMap(registry);
335
+ const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
288
336
  const body = request.body;
289
337
  const formQuery = {
290
338
  response_type: body.response_type,
@@ -296,11 +344,11 @@ function registerOauth2Routes(fastify, deps) {
296
344
  };
297
345
  // Re-validated, not trusted: the hidden fields came back from a browser
298
346
  // and every one of them is attacker-editable.
299
- const v = validateAuthorizeQuery(formQuery, descriptorMap);
347
+ const v = validateAuthorizeQuery(formQuery, descriptors, pendingAddons);
300
348
  if (!v.ok) {
301
349
  return refuseAuthorize(reply, deps.logger, 'POST', v);
302
350
  }
303
- const descriptor = descriptorMap.get(v.integration);
351
+ const descriptor = descriptors.get(v.integration);
304
352
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
305
353
  deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
306
354
  meta: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.82",
3
+ "version": "1.2.83",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -39,12 +39,12 @@
39
39
  "@camstack/addon-decoder-nodeav": "1.2.9",
40
40
  "@camstack/addon-notifiers": "1.2.13",
41
41
  "@camstack/addon-pipeline": "1.2.52",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.33",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.34",
43
43
  "@camstack/addon-post-analysis": "1.2.56",
44
44
  "@camstack/sdk": "1.2.11",
45
45
  "@camstack/shm-ring": "1.1.9",
46
- "@camstack/system": "1.2.69",
47
- "@camstack/types": "1.2.53",
46
+ "@camstack/system": "1.2.70",
47
+ "@camstack/types": "1.2.54",
48
48
  "@camstack/ui-library": "1.2.38",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",