@camstack/server 1.2.80 → 1.2.81

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.
@@ -7,6 +7,43 @@ exports.registerOauth2Routes = registerOauth2Routes;
7
7
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
8
8
  const consent_page_js_1 = require("./consent-page.js");
9
9
  const private_host_js_1 = require("./private-host.js");
10
+ /** Longest caller-supplied `integration` value echoed back in an error. */
11
+ const MAX_ECHOED_INTEGRATION_LEN = 100;
12
+ /** Render the received `integration` for a human, bounded. */
13
+ function describeReceivedIntegration(raw) {
14
+ if (raw === undefined)
15
+ return null;
16
+ const joined = typeof raw === 'string' ? raw : raw.join(',');
17
+ if (joined === '')
18
+ return null;
19
+ return joined.length > MAX_ECHOED_INTEGRATION_LEN
20
+ ? `${joined.slice(0, MAX_ECHOED_INTEGRATION_LEN)}…`
21
+ : joined;
22
+ }
23
+ /**
24
+ * Resolve the `integration` parameter to a single id.
25
+ *
26
+ * A repeat carrying ONE distinct value is collapsed rather than refused:
27
+ * every layer that parses the query — proxy, framework, app — necessarily
28
+ * agrees on the value, so there is no parameter-pollution ambiguity to
29
+ * protect against, and refusing would strand a client whose request is
30
+ * merely redundant. A repeat whose values DISAGREE is exactly that
31
+ * ambiguity, and is refused.
32
+ */
33
+ function resolveIntegrationParam(raw) {
34
+ if (raw === undefined)
35
+ return { ok: false, reason: 'missing' };
36
+ if (typeof raw === 'string') {
37
+ return raw === '' ? { ok: false, reason: 'missing' } : { ok: true, value: raw };
38
+ }
39
+ const distinct = [...new Set(raw.filter((v) => v !== ''))];
40
+ const [first] = distinct;
41
+ if (first === undefined)
42
+ return { ok: false, reason: 'missing' };
43
+ if (distinct.length > 1)
44
+ return { ok: false, reason: 'conflicting-repeat' };
45
+ return { ok: true, value: first };
46
+ }
10
47
  /** Validate the inbound authorize query. `client_id` is intentionally
11
48
  * NOT checked — that pair is verified only at the Lambda boundary, and a
12
49
  * PUBLIC client (`requiresPkce`) has no secret to check it against at all;
@@ -14,9 +51,24 @@ const private_host_js_1 = require("./private-host.js");
14
51
  function validateAuthorizeQuery(q, knownIntegrations) {
15
52
  if (q.response_type !== 'code')
16
53
  return { ok: false, status: 400, error: 'unsupported_response_type' };
17
- const policy = q.integration ? knownIntegrations.get(q.integration) : undefined;
18
- if (!q.integration || !policy)
19
- return { ok: false, status: 400, error: 'invalid_request — unknown integration' };
54
+ // Three distinct client faults used to collapse into one opaque string.
55
+ // Alexa linking was down for a day because the message named neither what
56
+ // arrived nor what would have been accepted.
57
+ const detail = {
58
+ received: describeReceivedIntegration(q.integration),
59
+ known_integrations: [...knownIntegrations.keys()],
60
+ };
61
+ const param = resolveIntegrationParam(q.integration);
62
+ if (!param.ok) {
63
+ const error = param.reason === 'missing'
64
+ ? 'invalid_request — integration parameter missing'
65
+ : 'invalid_request — integration parameter repeated with conflicting values';
66
+ return { ok: false, status: 400, error, detail };
67
+ }
68
+ const policy = knownIntegrations.get(param.value);
69
+ if (!policy) {
70
+ return { ok: false, status: 400, error: 'invalid_request — unknown integration', detail };
71
+ }
20
72
  if (!q.redirect_uri)
21
73
  return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' };
22
74
  if (!q.state)
@@ -32,7 +84,7 @@ function validateAuthorizeQuery(q, knownIntegrations) {
32
84
  }
33
85
  return {
34
86
  ok: true,
35
- integration: q.integration,
87
+ integration: param.value,
36
88
  redirectUri: q.redirect_uri,
37
89
  state: q.state,
38
90
  codeChallenge: challenge,
@@ -76,6 +128,20 @@ function summariseScopes(scopes) {
76
128
  }
77
129
  return scopes.map((s) => s.type).join(', ') || 'no permissions';
78
130
  }
131
+ /** A refused `/authorize`. Logged at `warn` and echoed to the caller: both
132
+ * halves name the value that ARRIVED and the ids that would have worked, so
133
+ * neither the operator nor the log reader has to guess which of them is
134
+ * wrong. */
135
+ function refuseAuthorize(reply, logger, method, refusal) {
136
+ logger.warn(`oauth2 authorize refused: ${refusal.error}`, {
137
+ meta: {
138
+ method,
139
+ received: refusal.detail?.received ?? null,
140
+ knownIntegrations: refusal.detail?.known_integrations ?? [],
141
+ },
142
+ });
143
+ return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
144
+ }
79
145
  /** Build a map of integrationId → descriptor from all registered oauth-integration providers. */
80
146
  async function buildIntegrationMap(registry) {
81
147
  const entries = registry.getCollectionEntries('oauth-integration');
@@ -137,10 +203,19 @@ function registerOauth2Routes(fastify, deps) {
137
203
  const query = request.query;
138
204
  const v = validateAuthorizeQuery(query, descriptorMap);
139
205
  if (!v.ok) {
140
- return reply.status(v.status).send({ error: v.error });
206
+ return refuseAuthorize(reply, deps.logger, 'GET', v);
141
207
  }
142
208
  const descriptor = descriptorMap.get(v.integration);
143
209
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
210
+ deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
211
+ meta: {
212
+ method: 'GET',
213
+ integration: v.integration,
214
+ redirectUri: v.redirectUri,
215
+ allowedPrefixes: descriptor.allowedRedirectPrefixes,
216
+ allowedPrivateHostPaths: descriptor.allowedPrivateHostPaths ?? [],
217
+ },
218
+ });
144
219
  return reply
145
220
  .status(400)
146
221
  .send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
@@ -223,10 +298,19 @@ function registerOauth2Routes(fastify, deps) {
223
298
  // and every one of them is attacker-editable.
224
299
  const v = validateAuthorizeQuery(formQuery, descriptorMap);
225
300
  if (!v.ok) {
226
- return reply.status(v.status).send({ error: v.error });
301
+ return refuseAuthorize(reply, deps.logger, 'POST', v);
227
302
  }
228
303
  const descriptor = descriptorMap.get(v.integration);
229
304
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
305
+ deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
306
+ meta: {
307
+ method: 'POST',
308
+ integration: v.integration,
309
+ redirectUri: v.redirectUri,
310
+ allowedPrefixes: descriptor.allowedRedirectPrefixes,
311
+ allowedPrivateHostPaths: descriptor.allowedPrivateHostPaths ?? [],
312
+ },
313
+ });
230
314
  return reply
231
315
  .status(400)
232
316
  .send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
@@ -7794,6 +7794,15 @@ function createCapRouter_snapshot(getProvider, createRemoteProxy) {
7794
7794
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7795
7795
  return p.getSnapshotOverview(methodInput);
7796
7796
  }),
7797
+ getSnapshotLinks: trpc_middleware_js_1.protectedProcedure
7798
+ .input(types_98.snapshotCapability.methods.getSnapshotLinks.input.loose())
7799
+ .output(types_98.snapshotCapability.methods.getSnapshotLinks.output)
7800
+ .query(async ({ input, ctx }) => {
7801
+ const { nodeId, ...methodInput } = input;
7802
+ const p = resolveProvider('snapshot', nodeId, () => getProvider(ctx), createRemoteProxy);
7803
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7804
+ return p.getSnapshotLinks(methodInput);
7805
+ }),
7797
7806
  });
7798
7807
  }
7799
7808
  function createCapRouter_ssoBridge(getProvider, createRemoteProxy) {
package/dist/main.js CHANGED
@@ -958,6 +958,10 @@ async function bootstrap() {
958
958
  getRegistry: () => capabilityRegistry,
959
959
  verifyToken: (t) => authService.verifyToken(t),
960
960
  publicHubUrl: () => process.env.CAMSTACK_PUBLIC_ORIGIN ?? `https://localhost:${port}`,
961
+ // Every /authorize refusal is a dropped account-linking attempt. Without
962
+ // this scope the drop is invisible in Loki and the only signal left is an
963
+ // operator noticing that linking stopped working.
964
+ logger: app.get(logging_service_1.LoggingService).createLogger('oauth2'),
961
965
  });
962
966
  console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*');
963
967
  // Attach tRPC WebSocket handler using noServer mode to avoid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.80",
3
+ "version": "1.2.81",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -40,12 +40,12 @@
40
40
  "@camstack/addon-notifiers": "1.2.13",
41
41
  "@camstack/addon-pipeline": "1.2.52",
42
42
  "@camstack/addon-pipeline-orchestrator": "1.2.33",
43
- "@camstack/addon-post-analysis": "1.2.55",
43
+ "@camstack/addon-post-analysis": "1.2.56",
44
44
  "@camstack/sdk": "1.2.10",
45
45
  "@camstack/shm-ring": "1.1.9",
46
- "@camstack/system": "1.2.67",
47
- "@camstack/types": "1.2.51",
48
- "@camstack/ui-library": "1.2.36",
46
+ "@camstack/system": "1.2.68",
47
+ "@camstack/types": "1.2.52",
48
+ "@camstack/ui-library": "1.2.37",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",