@camstack/server 1.2.605 → 1.2.607

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/api/auth-whoami.js +0 -1
  2. package/dist/api/core/auth-audit.procedures.js +44 -0
  3. package/dist/api/core/auth-revoke-credentials.procedures.js +4 -78
  4. package/dist/api/core/auth-tokens.procedures.js +105 -72
  5. package/dist/api/core/auth.router.js +14 -280
  6. package/dist/api/core/cap-providers.js +38 -1
  7. package/dist/api/core/integration-secrets.js +22 -1
  8. package/dist/api/core/logs.router.js +219 -49
  9. package/dist/api/core/preferences.router.js +98 -0
  10. package/dist/api/oauth2/oauth2-routes.js +40 -15
  11. package/dist/api/trpc/device-config-secret-redaction.js +2 -2
  12. package/dist/api/trpc/generated-cap-mounts.js +0 -1
  13. package/dist/api/trpc/generated-cap-routers.js +53 -42
  14. package/dist/api/trpc/principal-visibility.js +2 -2
  15. package/dist/api/trpc/share-view-access.js +1 -1
  16. package/dist/api/trpc/trpc-error-principal.js +1 -2
  17. package/dist/api/trpc/trpc.context.js +16 -13
  18. package/dist/api/trpc/trpc.router.js +7 -2
  19. package/dist/api/upload-auth.js +13 -25
  20. package/dist/auth/addon-route-share-gate.js +2 -2
  21. package/dist/auth/credential-verifier.js +63 -104
  22. package/dist/auth/embed-auth-route.js +45 -0
  23. package/dist/auth/live-user.js +19 -0
  24. package/dist/auth/scope-escalation.js +77 -2
  25. package/dist/boot/boot-config.js +8 -1
  26. package/dist/core/addon/addon-registry.service.js +14 -20
  27. package/dist/core/auth/credential-token.service.js +9 -14
  28. package/dist/core/auth/handoff-code.service.js +1 -1
  29. package/dist/core/auth/record-kinds.js +7 -2
  30. package/dist/core/logging/log-history.js +119 -0
  31. package/dist/core/logging/logging.service.js +38 -1
  32. package/dist/main.js +18 -48
  33. package/package.json +14 -14
  34. package/dist/api/core/auth-admin-tokens.procedures.js +0 -156
  35. package/dist/core/auth/share-token.service.js +0 -459
@@ -32,11 +32,9 @@ const session_cookie_js_1 = require("../../auth/session-cookie.js");
32
32
  const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
33
33
  const session_mint_js_1 = require("../../auth/session-mint.js");
34
34
  const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
35
- const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
36
- const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
37
35
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
38
36
  const auth_limits_js_1 = require("./auth-limits.js");
39
- const auth_admin_tokens_procedures_js_1 = require("./auth-admin-tokens.procedures.js");
37
+ const auth_audit_procedures_js_1 = require("./auth-audit.procedures.js");
40
38
  const auth_tokens_procedures_js_1 = require("./auth-tokens.procedures.js");
41
39
  const auth_revoke_credentials_procedures_js_1 = require("./auth-revoke-credentials.procedures.js");
42
40
  const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
@@ -170,59 +168,14 @@ const OwnExternalIdentitySchema = zod_1.z.object({
170
168
  /** Whether the provider instance is configured and enabled right now. */
171
169
  available: zod_1.z.boolean(),
172
170
  });
173
- // ── Share tokens (grid-view standalone share links) ──────────────────
174
- //
175
- // Wire schemas for the `csv_*` share-token surface. The summary shape
176
- // deliberately omits `tokenHash` — the raw token is returned exactly
177
- // once (at mint); afterwards only prefix + scope + timestamps are
178
- // visible.
179
- //
180
- // TTL: a bounded number of seconds, or the EXPLICIT literal `'never'`
181
- // for a permanently-shared grid (revocation is the kill switch).
182
- // Omitted → 7-day default; infinite is never a silent default.
183
- const CreateShareTokenInputSchema = zod_1.z.object({
184
- scope: share_token_service_js_1.ShareTokenScopeSchema,
185
- ttlSec: zod_1.z
186
- .union([
187
- zod_1.z.number().int().min(share_token_service_js_1.SHARE_TOKEN_TTL_MIN_SEC).max(share_token_service_js_1.SHARE_TOKEN_TTL_MAX_SEC),
188
- zod_1.z.literal('never'),
189
- ])
190
- .optional(),
191
- });
192
- const ShareTokenSummarySchema = zod_1.z.object({
193
- id: zod_1.z.string(),
194
- userId: zod_1.z.string(),
195
- tokenPrefix: zod_1.z.string(),
196
- scope: share_token_service_js_1.ShareTokenScopeSchema,
197
- createdAt: zod_1.z.number(),
198
- /** Epoch ms, or `null` for never-expiring tokens. */
199
- expiresAt: zod_1.z.number().nullable(),
200
- /** Derived convenience flag so listings surface infinite tokens clearly. */
201
- neverExpires: zod_1.z.boolean(),
202
- lastUsedAt: zod_1.z.number().optional(),
203
- /** A pre-D892 `csv_` link: valid until it expires, never minted again. */
204
- legacy: zod_1.z.boolean().optional(),
205
- });
206
- function toShareTokenSummary(record) {
207
- return {
208
- id: record.id,
209
- userId: record.userId,
210
- tokenPrefix: record.tokenPrefix,
211
- scope: record.scope,
212
- createdAt: record.createdAt,
213
- expiresAt: record.expiresAt,
214
- neverExpires: record.expiresAt === null,
215
- ...(record.lastUsedAt !== undefined ? { lastUsedAt: record.lastUsedAt } : {}),
216
- };
217
- }
218
171
  /**
219
172
  * Token minting/management surfaces are for REAL user sessions only.
220
- * Scoped API tokens (`cst_*`) and share-view principals (`csv_*`) must
221
- * never mint, list, or revoke share links — nor mint handoff codes —
222
- * a leaked restricted token would otherwise widen its own reach.
173
+ * A minted token — whatever its scopes — must never mint handoff codes or
174
+ * link sign-in accounts: a leaked restricted token would otherwise widen its
175
+ * own reach.
223
176
  */
224
- function assertRealUserSession(user, what = 'Share-token management') {
225
- // A minted token (admin, scoped, share — legacy or unified) is a script's
177
+ function assertRealUserSession(user, what) {
178
+ // A minted token (admin, scoped or share-view) is a script's
226
179
  // credential, never a person's: it must not turn itself into a session
227
180
  // (handoff codes) or mint links. Provenance, not permission (D892).
228
181
  if (user.userDelegated !== true) {
@@ -301,7 +254,7 @@ const MeSchema = zod_1.z
301
254
  scope: EffectiveScopeSchema.optional(),
302
255
  })
303
256
  .nullable();
304
- function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents(), nativeLink = null, options = {}) {
257
+ function createAuthRouter(auth, registry, moleculer = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents(), nativeLink = null, options = {}) {
305
258
  const network = options.network ?? auth_network_policy_js_1.DEFAULT_AUTH_NETWORK_POLICY;
306
259
  const limiters = options.limiters ?? auth_limits_js_1.PROCESS_AUTH_LIMITERS;
307
260
  /** Secret-verifying legs (password / TOTP / passkey assertion / codes). */
@@ -309,20 +262,6 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
309
262
  /** Challenge-minting begin-legs — looser, they verify nothing. */
310
263
  const ceremonyProcedure = makeRateLimitedProcedure(limiters.ceremony, network);
311
264
  const credentialTokens = options.credentialTokens ?? null;
312
- const tokenDeps = { auth, registry, network, credentialTokens, shareTokens };
313
- /** The unified share link behind `id`, or `null` (a legacy `csv_`, or none). */
314
- const unifiedShare = async (id) => {
315
- if (credentialTokens === null)
316
- return null;
317
- const record = await credentialTokens.get(id);
318
- return record !== null && (0, types_2.shareViewGrantOf)(record.scopes) !== null ? record : null;
319
- };
320
- const requireShareTokens = () => {
321
- if (!shareTokens) {
322
- throw new Error('Share tokens unavailable — service not wired on this node');
323
- }
324
- return shareTokens;
325
- };
326
265
  /**
327
266
  * THE session mint (`auth/session-mint.ts`, D712) — the single shared tail
328
267
  * of every successful login leg, including `/api/auth/sso/finish`. It
@@ -800,8 +739,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
800
739
  * semantics (see `buildSessionCookie` — Lax already withholds
801
740
  * the cookie from cross-site POSTs).
802
741
  * • Only REAL session JWTs qualify: bridge tokens (`kind:
803
- * 'totp-challenge'` / `'sso-bridge'`) and `cst_`/`csv_` opaque
804
- * tokens are rejected — the cookie must carry a v2 session.
742
+ * 'totp-challenge'` / `'sso-bridge'`) and minted tokens are
743
+ * rejected — the cookie must carry a v2 session.
805
744
  * • The returned bearer is minted FRESH through the shared
806
745
  * `mintSessionForUserId` tail (re-fetched user → up-to-date
807
746
  * scopes), equivalent to what `auth.login` hands out.
@@ -851,8 +790,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
851
790
  // for a real session. TTL 60s, single-use, bound to the minting
852
791
  // user — see `HandoffCodeService`.
853
792
  /** Mint a one-time handoff code for the CALLING user. Real user
854
- * sessions only — scoped (`cst_`) and share-view (`csv_`) callers
855
- * must never convert themselves into a full session. */
793
+ * sessions only — a minted token (scoped, admin or share-view)
794
+ * must never convert itself into a full session. */
856
795
  createHandoffCode: trpc_middleware_js_1.protectedProcedure
857
796
  .input(zod_1.z
858
797
  .object({
@@ -1191,211 +1130,6 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1191
1130
  enabled: input.enabled,
1192
1131
  });
1193
1132
  }),
1194
- // ── Share tokens — scoped, TTL'd view tokens for standalone share
1195
- // links (grid rework R6). Minted by any REAL user session (admin
1196
- // or regular); the resulting `csv_*` token authenticates as a
1197
- // restricted `share-view` principal limited to the embed surface
1198
- // + its in-scope devices (see `share-view-access.ts`). ──────────
1199
- createShareToken: trpc_middleware_js_1.protectedProcedure
1200
- .input(CreateShareTokenInputSchema)
1201
- .output(zod_1.z.object({ id: zod_1.z.string(), token: zod_1.z.string(), expiresAt: zod_1.z.number().nullable() }))
1202
- .mutation(async ({ input, ctx }) => {
1203
- assertRealUserSession(ctx.user);
1204
- // SECURITY (F0.4): a share token is a PRIVILEGE HANDOFF — it authorises
1205
- // whoever holds the link to view its devices with no further auth. A
1206
- // scoped (non-admin) minter must therefore not be able to mint for a
1207
- // device it cannot itself see: without this, a `device:[5]` token could
1208
- // mint a `csv_*` grid link for cameras 5..99. Admins mint anything;
1209
- // every other caller's requested deviceIds are INTERSECTED with what
1210
- // they can view (device:[parent] covers child accessories via the
1211
- // ancestor walk). An empty intersection is a refusal, not a silent
1212
- // "share nothing".
1213
- const scope = ctx.user.isAdmin
1214
- ? input.scope
1215
- : {
1216
- ...input.scope,
1217
- deviceIds: (0, principal_visibility_js_1.filterViewableDeviceIds)((0, types_2.capabilityScopesOf)(ctx.user.scopes ?? []), input.scope.deviceIds, ctx.deviceScopeLookup),
1218
- };
1219
- if (scope.deviceIds.length === 0) {
1220
- throw new server_1.TRPCError({
1221
- code: 'FORBIDDEN',
1222
- message: 'None of the requested devices are within your scope — you cannot mint a share token for devices you cannot see',
1223
- });
1224
- }
1225
- // A thin wrapper for one release (D892): the link is a unified token
1226
- // whose only scope is the share-view grant. Without the store (a
1227
- // node that cannot mint) it is still a legacy `csv_`.
1228
- if (credentialTokens !== null) {
1229
- const ttl = input.ttlSec ?? share_token_service_js_1.SHARE_TOKEN_TTL_DEFAULT_SEC;
1230
- const minted = await (0, auth_tokens_procedures_js_1.mintFor)(tokenDeps, ctx, {
1231
- scopes: [{ type: 'share-view', grant: scope }],
1232
- expiresIn: ttl,
1233
- label: `share ${scope.kind}`,
1234
- allowAccountLink: true,
1235
- });
1236
- return { id: minted.record.id, token: minted.token, expiresAt: minted.record.expiresAt };
1237
- }
1238
- const { token, record } = await requireShareTokens().create({
1239
- userId: ctx.user.id,
1240
- scope,
1241
- ...(input.ttlSec !== undefined ? { ttlSec: input.ttlSec } : {}),
1242
- });
1243
- return { id: record.id, token, expiresAt: record.expiresAt };
1244
- }),
1245
- /**
1246
- * Slide expiry on a still-valid share token (same `csv_*`). Used by the
1247
- * Home Assistant embed cache so a long-lived dashboard does not mint a
1248
- * new row every hour. Expired / missing → NOT_FOUND (caller must create).
1249
- */
1250
- extendShareToken: trpc_middleware_js_1.protectedProcedure
1251
- .input(zod_1.z.object({
1252
- id: zod_1.z.string().min(1),
1253
- ttlSec: zod_1.z.number().int().min(share_token_service_js_1.SHARE_TOKEN_TTL_MIN_SEC).max(share_token_service_js_1.SHARE_TOKEN_TTL_MAX_SEC),
1254
- }))
1255
- .output(zod_1.z.object({ id: zod_1.z.string(), expiresAt: zod_1.z.number() }))
1256
- .mutation(async ({ input, ctx }) => {
1257
- assertRealUserSession(ctx.user);
1258
- const unified = await unifiedShare(input.id);
1259
- if (unified !== null && credentialTokens !== null) {
1260
- if (!ctx.user.isAdmin && unified.subjectId !== ctx.user.id) {
1261
- throw new server_1.TRPCError({
1262
- code: 'FORBIDDEN',
1263
- message: 'Only the token owner or an admin can extend a share token',
1264
- });
1265
- }
1266
- if (unified.expiresAt === null) {
1267
- throw (0, user_error_js_1.trpcUserError)('BAD_REQUEST', 'auth.share-never-expiring-extend', undefined, 'A never-expiring share token cannot be extended');
1268
- }
1269
- const moved = await credentialTokens.setExpiry(input.id, Date.now() + input.ttlSec * 1000);
1270
- if (moved === null || moved.expiresAt === null) {
1271
- throw new server_1.TRPCError({ code: 'NOT_FOUND', message: `Share token expired: ${input.id}` });
1272
- }
1273
- return { id: moved.id, expiresAt: moved.expiresAt };
1274
- }
1275
- const service = requireShareTokens();
1276
- try {
1277
- const record = await service.extend({
1278
- id: input.id,
1279
- ttlSec: input.ttlSec,
1280
- callerUserId: ctx.user.id,
1281
- callerIsAdmin: ctx.user.isAdmin,
1282
- });
1283
- if (record.expiresAt === null) {
1284
- throw (0, user_error_js_1.trpcUserError)('BAD_REQUEST', 'auth.share-never-expiring-extend', undefined, 'A never-expiring share token cannot be extended');
1285
- }
1286
- return { id: record.id, expiresAt: record.expiresAt };
1287
- }
1288
- catch (error) {
1289
- const message = error instanceof Error ? error.message : 'Share token extend failed';
1290
- if (/not found|expired/i.test(message)) {
1291
- throw new server_1.TRPCError({ code: 'NOT_FOUND', message });
1292
- }
1293
- if (/owner or an admin|never-expiring/i.test(message)) {
1294
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message });
1295
- }
1296
- throw new server_1.TRPCError({ code: 'BAD_REQUEST', message });
1297
- }
1298
- }),
1299
- revokeShareToken: trpc_middleware_js_1.protectedProcedure
1300
- .input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
1301
- .output(zod_1.z.object({ success: zod_1.z.boolean() }))
1302
- .mutation(async ({ input, ctx }) => {
1303
- assertRealUserSession(ctx.user);
1304
- const unified = await unifiedShare(input.id);
1305
- if (unified !== null && credentialTokens !== null) {
1306
- if (!ctx.user.isAdmin && unified.subjectId !== ctx.user.id) {
1307
- throw new server_1.TRPCError({
1308
- code: 'FORBIDDEN',
1309
- message: 'Only the token owner or an admin can revoke a share token',
1310
- });
1311
- }
1312
- return { success: await credentialTokens.revoke(input.id) };
1313
- }
1314
- const service = requireShareTokens();
1315
- try {
1316
- const removed = await service.revoke({
1317
- id: input.id,
1318
- callerUserId: ctx.user.id,
1319
- callerIsAdmin: ctx.user.isAdmin,
1320
- });
1321
- if (removed) {
1322
- const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
1323
- if (internal !== null) {
1324
- const ip = network.clientAddress(ctx.req).ip;
1325
- await internal.audit.record({
1326
- action: 'credentials-revoked',
1327
- userId: ctx.user.id,
1328
- ...(ip !== null ? { ip } : {}),
1329
- detail: {
1330
- kind: 'share-link',
1331
- requested: 1,
1332
- revoked: 1,
1333
- skipped: 0,
1334
- ids: input.id,
1335
- },
1336
- });
1337
- }
1338
- }
1339
- return { success: removed };
1340
- }
1341
- catch (error) {
1342
- throw new server_1.TRPCError({
1343
- code: 'FORBIDDEN',
1344
- message: error instanceof Error ? error.message : 'Share token revoke failed',
1345
- });
1346
- }
1347
- }),
1348
- /**
1349
- * Share links. `owner: 'self'` — the caller's own (My access);
1350
- * `owner: 'all'` — every user's, admin only. Omitted keeps the original
1351
- * answer (own for a user, all for an admin) for clients that predate it.
1352
- */
1353
- listShareTokens: trpc_middleware_js_1.protectedProcedure
1354
- .input(zod_1.z.object({ owner: zod_1.z.enum(['self', 'all']).optional() }).optional())
1355
- .output(zod_1.z.array(ShareTokenSummarySchema))
1356
- .query(async ({ ctx, input }) => {
1357
- assertRealUserSession(ctx.user);
1358
- // The legacy `csv_` store, or — on a node with only the unified store —
1359
- // none; with neither, the clear refusal below.
1360
- const service = credentialTokens === null ? requireShareTokens() : shareTokens;
1361
- const owner = input?.owner ?? (ctx.user.isAdmin ? 'all' : 'self');
1362
- if (owner === 'all' && !ctx.user.isAdmin) {
1363
- throw new server_1.TRPCError({
1364
- code: 'FORBIDDEN',
1365
- message: "Listing every user's share links requires an admin",
1366
- });
1367
- }
1368
- const records = service === null
1369
- ? []
1370
- : owner === 'all'
1371
- ? await service.listAll()
1372
- : await service.listForUser(ctx.user.id);
1373
- const unified = credentialTokens === null
1374
- ? []
1375
- : await credentialTokens.list(owner === 'all' ? undefined : ctx.user.id);
1376
- const unifiedRows = unified.flatMap((record) => {
1377
- const grant = (0, types_2.shareViewGrantOf)(record.scopes);
1378
- if (grant === null || record.revokedAt !== null)
1379
- return [];
1380
- return [
1381
- {
1382
- id: record.id,
1383
- userId: record.subjectId,
1384
- tokenPrefix: `token:${record.id.slice(0, 8)}`,
1385
- scope: grant,
1386
- createdAt: record.createdAt,
1387
- expiresAt: record.expiresAt,
1388
- neverExpires: record.expiresAt === null,
1389
- ...(record.lastUsedAt !== null ? { lastUsedAt: record.lastUsedAt } : {}),
1390
- legacy: false,
1391
- },
1392
- ];
1393
- });
1394
- return [
1395
- ...unifiedRows,
1396
- ...records.map((record) => ({ ...toShareTokenSummary(record), legacy: true })),
1397
- ];
1398
- }),
1399
1133
  ...(0, auth_sessions_procedures_js_1.createSessionProcedures)({ registry, minter, credentialProcedure }),
1400
1134
  ...(0, auth_device_cert_procedures_js_1.createDeviceCertProcedures)({
1401
1135
  registry,
@@ -1413,9 +1147,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1413
1147
  issueFor,
1414
1148
  }),
1415
1149
  ...(0, auth_reauth_procedures_js_1.createReauthProcedures)({ auth, registry, moleculer, limiters }),
1416
- ...(0, auth_admin_tokens_procedures_js_1.createAdminTokenProcedures)({ auth, registry, network, credentialTokens, shareTokens }),
1417
- ...(0, auth_tokens_procedures_js_1.createTokenProcedures)({ auth, registry, network, credentialTokens, shareTokens }),
1418
- ...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry, shareTokens, network, credentialTokens }),
1150
+ ...(0, auth_audit_procedures_js_1.createAuthAuditProcedures)({ registry }),
1151
+ ...(0, auth_tokens_procedures_js_1.createTokenProcedures)({ auth, registry, network, credentialTokens }),
1152
+ ...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry, network, credentialTokens }),
1419
1153
  // ── External sign-in identities (D782) ────────────────────────
1420
1154
  //
1421
1155
  // A signed-in user links an OIDC identity to THEIR OWN account. The
@@ -719,6 +719,28 @@ function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRoot
719
719
  function ownerSecrets(ar, owner) {
720
720
  return typeof ar.secretsForOwner === 'function' ? ar.secretsForOwner(owner) : null;
721
721
  }
722
+ /**
723
+ * The integration settings keys the addon's own form stores in the keychain
724
+ * (by schema type, D783) — so the admin read can withhold a plaintext under one
725
+ * of them. Empty when the schema cannot be read (logged): markers are still
726
+ * withheld, and a coerced marker is caught without a schema.
727
+ */
728
+ async function integrationSecretKeys(ar, addonId, logger) {
729
+ try {
730
+ const provider = ar
731
+ .getCapabilityRegistry()
732
+ .getSingleton('addon-settings');
733
+ const schema = await provider?.getGlobalSettings({ addonId, nodeId: 'hub' });
734
+ return (0, types_1.collectKeychainStoredKeys)(schema ?? null);
735
+ }
736
+ catch (err) {
737
+ logger.warn('integration settings: addon schema unreadable — plaintext secrets not detectable', {
738
+ tags: { addonId },
739
+ meta: { phase: 'getSettings', error: (0, types_1.errMsg)(err) },
740
+ });
741
+ return new Set();
742
+ }
743
+ }
722
744
  function requireIntegrationRegistry(ar) {
723
745
  const reg = ar.getIntegrationRegistry();
724
746
  if (!reg)
@@ -1143,7 +1165,22 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
1143
1165
  const caller = Reflect.get(input, 'callerAddonId');
1144
1166
  const forOwner = typeof caller === 'string' &&
1145
1167
  (caller === integration.addonId || ar.runnerIdFor(integration.addonId) === caller);
1146
- return (0, integration_secrets_1.readIntegrationSettings)({ registry: reg, secrets: forOwner ? ownerSecrets(ar, integration.addonId) : null }, { integrationId: input.id, forOwner });
1168
+ // The schema is asked only for the admin read: the owner reads this at
1169
+ // its own boot, and asking it for its schema then would call back into it.
1170
+ const secretKeys = forOwner
1171
+ ? undefined
1172
+ : await integrationSecretKeys(ar, integration.addonId, logger);
1173
+ return (0, integration_secrets_1.readIntegrationSettings)({ registry: reg, secrets: forOwner ? ownerSecrets(ar, integration.addonId) : null }, {
1174
+ integrationId: input.id,
1175
+ forOwner,
1176
+ ...(secretKeys !== undefined ? { secretKeys } : {}),
1177
+ onSuspectSecret: (suspect) => logger.error(suspect.kind === 'coerced-marker'
1178
+ ? 'integration secret is "[object Object]" — a keychain marker was coerced to text; the addon is sending it as the credential (repair: scripts/hub/repair-integration-secret-markers.ts, or re-type it)'
1179
+ : 'integration secret stored as PLAINTEXT under a keychain-declared key — withheld from the form; re-type it to move it into the keychain', {
1180
+ tags: { integrationId: input.id, addonId: integration.addonId },
1181
+ meta: { phase: 'getSettings', key: suspect.key, kind: suspect.kind, forOwner },
1182
+ }),
1183
+ });
1147
1184
  },
1148
1185
  setSettings: async (input) => {
1149
1186
  const reg = requireIntegrationRegistry(ar);
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COERCED_MARKER_TEXT = void 0;
3
4
  exports.writeIntegrationSettings = writeIntegrationSettings;
4
5
  exports.readIntegrationSettings = readIntegrationSettings;
5
6
  /**
@@ -45,16 +46,36 @@ async function writeIntegrationSettings(deps, input) {
45
46
  if (orphaned.length > 0)
46
47
  deps.onOrphaned?.(orphaned);
47
48
  }
49
+ /** The text `String(...)` makes of an object — a keychain marker some copy coerced. */
50
+ exports.COERCED_MARKER_TEXT = '[object Object]';
51
+ function suspectKind(key, value, secretKeys) {
52
+ if (value === exports.COERCED_MARKER_TEXT)
53
+ return 'coerced-marker';
54
+ if (secretKeys?.has(key) === true && typeof value === 'string' && value.length > 0) {
55
+ return 'plaintext';
56
+ }
57
+ return null;
58
+ }
48
59
  async function readIntegrationSettings(deps, input) {
49
60
  const stored = await deps.registry.getIntegrationSettings(input.integrationId);
61
+ const suspects = new Set();
62
+ for (const [key, value] of Object.entries(stored)) {
63
+ const kind = suspectKind(key, value, input.secretKeys);
64
+ if (kind === null)
65
+ continue;
66
+ suspects.add(key);
67
+ input.onSuspectSecret?.({ key, kind });
68
+ }
50
69
  if (input.forOwner) {
51
70
  if (deps.secrets === null)
52
71
  return stored;
53
72
  return (await (0, types_1.resolveSecretMarkers)(stored, deps.secrets)).value;
54
73
  }
74
+ // Anyone but the owner — the admin form — never receives a secret's text:
75
+ // a marker becomes its "set" view, and so does a suspect plaintext.
55
76
  const out = {};
56
77
  for (const [key, value] of Object.entries(stored)) {
57
- out[key] = (0, types_1.isSecretMarker)(value) ? (0, types_1.toSecretFieldView)(value) : value;
78
+ out[key] = (0, types_1.isSecretMarker)(value) || suspects.has(key) ? (0, types_1.toSecretFieldView)(value) : value;
58
79
  }
59
80
  return out;
60
81
  }