@cosmicdrift/kumiko-samples 0.392.1 → 0.392.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.392.1",
3
+ "version": "0.392.2",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.392.1",
3
+ "version": "0.392.2",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.392.2",
4
+ "type": "fix",
5
+ "title": "Mail sync no longer fails when reading IMAP credentials",
6
+ "detail": "createInboundMailSupervisor builds its provider context with auditUserId defaulting to the system user. Apps that did not set auditUserId in providerCtx got an internal error on every fetch and watch because the secrets context needs an audit identity. An explicitly set auditUserId is kept."
7
+ },
8
+ {
9
+ "version": "0.392.2",
10
+ "type": "fix",
11
+ "title": "Mail sync finds the stored IMAP credentials under row-level security",
12
+ "detail": "createInboundMailSupervisor runs outside any request, so secret reads ran without a tenant scope and the application role saw no row in read_tenant_secrets. The supervisor now wraps providerCtx.secrets with tenantScopedSecrets, which the framework exports from internal/row-level-security. inbound-provider-inmemory gains requireCredentialSecretOnFetch so tests can exercise credential reads; a new integration test runs the secrets against an application-role database."
13
+ },
2
14
  {
3
15
  "version": "0.392.0",
4
16
  "type": "fix",
@@ -24,7 +24,7 @@ import type { OutboundMailMessage, OutboundMailSent } from "./outbound-mail.ts";
24
24
  * secrets — tenant secret reads (IMAP password, OAuth refresh token)
25
25
  * auditUserId — audit identity for secret reads. Handler path: unset,
26
26
  * requireSecretsContext falls back to ctx.user.id; worker/provider
27
- * path: system identity set by the resolver.
27
+ * path: the supervisor sets the system identity.
28
28
  * metricsFor — metrics handle of a feature by name, same behavior as
29
29
  * HandlerContext.metricsFor: unregistered metrics are a silent
30
30
  * no-op instead of a throw.
@@ -49,7 +49,11 @@ import {
49
49
  } from "@cosmicdrift/kumiko-framework/crypto";
50
50
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
51
51
  import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/db";
52
- import { systemScopedRunner } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
52
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
53
+ import {
54
+ systemScopedRunner,
55
+ tenantScopedSecrets,
56
+ } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
53
57
  import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
54
58
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
55
59
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
@@ -186,17 +190,25 @@ export function createInboundMailSupervisor(
186
190
  // configured TTL — real deployments (default 90s) never hit it.
187
191
  const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
188
192
  const log = deps.log ?? (() => {});
193
+ // The supervisor runs outside any request: secret reads need an audit
194
+ // identity, and under enforced row-level security they need the account's
195
+ // tenant scope, or the credential row is invisible and reads as missing.
196
+ const providerCtx: InboundMailContext = {
197
+ ...deps.providerCtx,
198
+ auditUserId: deps.providerCtx.auditUserId ?? SYSTEM_USER_ID,
199
+ ...(deps.providerCtx.secrets && { secrets: tenantScopedSecrets(deps.providerCtx.secrets) }),
200
+ };
189
201
  const systemDb = systemScopedRunner(
190
202
  deps.db,
191
203
  "mail sync supervisor polls accounts and cursors of all tenants outside a request",
192
204
  );
193
205
  const oauthTokens =
194
206
  deps.oauthTokens ??
195
- (deps.providerCtx.secrets
207
+ (providerCtx.secrets
196
208
  ? createOAuthAccessTokenManager({
197
209
  db: systemDb,
198
- secrets: deps.providerCtx.secrets,
199
- providerCtx: deps.providerCtx,
210
+ secrets: providerCtx.secrets,
211
+ providerCtx,
200
212
  })
201
213
  : undefined);
202
214
 
@@ -447,7 +459,7 @@ export function createInboundMailSupervisor(
447
459
  // exhausted.
448
460
  for (;;) {
449
461
  const accessToken = await resolveAccessToken(account, plugin);
450
- const result = await plugin.fetch(deps.providerCtx, account, cursor, {
462
+ const result = await plugin.fetch(providerCtx, account, cursor, {
451
463
  backfillWindowDays,
452
464
  maxMessages: budget,
453
465
  filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
@@ -469,7 +481,7 @@ export function createInboundMailSupervisor(
469
481
  for (const account of accounts) {
470
482
  let plugin: InboundMailProviderPlugin;
471
483
  try {
472
- plugin = resolveInboundProviderForAccount(deps.providerCtx, account);
484
+ plugin = resolveInboundProviderForAccount(providerCtx, account);
473
485
  } catch (err) {
474
486
  log(`inbound-mail: ${describeError(err)}`);
475
487
  continue;
@@ -642,7 +654,7 @@ export function createInboundMailSupervisor(
642
654
  try {
643
655
  const accessToken = await resolveAccessToken(account, plugin);
644
656
  const stop = await plugin.watch(
645
- deps.providerCtx,
657
+ providerCtx,
646
658
  account,
647
659
  {
648
660
  onMessages: async (msgs) => {
@@ -11,13 +11,16 @@
11
11
  // **Pattern basis:** mail-transport-inmemory (module-level state +
12
12
  // test-helper exports). Not for production.
13
13
 
14
- import type {
15
- InboundFetchResult,
16
- InboundMailProviderPlugin,
17
- MailAccountRecord,
18
- RawInboundMessage,
19
- SyncCursorPayload,
14
+ import {
15
+ InboundAuthError,
16
+ type InboundFetchResult,
17
+ type InboundMailContext,
18
+ type InboundMailProviderPlugin,
19
+ type MailAccountRecord,
20
+ type RawInboundMessage,
21
+ type SyncCursorPayload,
20
22
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
23
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
21
24
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
22
25
 
23
26
  const FEATURE_NAME = "inbound-provider-inmemory";
@@ -38,6 +41,7 @@ type AccountState = {
38
41
  watch: WatchHandlers | null;
39
42
  nextFetchError: unknown;
40
43
  nextVerifyError: unknown;
44
+ credentialSecretKey: string | null;
41
45
  };
42
46
 
43
47
  const accounts = new Map<string, AccountState>();
@@ -45,7 +49,13 @@ const accounts = new Map<string, AccountState>();
45
49
  function stateFor(accountId: string): AccountState {
46
50
  let state = accounts.get(accountId);
47
51
  if (!state) {
48
- state = { log: [], watch: null, nextFetchError: null, nextVerifyError: null };
52
+ state = {
53
+ log: [],
54
+ watch: null,
55
+ nextFetchError: null,
56
+ nextVerifyError: null,
57
+ credentialSecretKey: null,
58
+ };
49
59
  accounts.set(accountId, state);
50
60
  }
51
61
  return state;
@@ -71,6 +81,25 @@ export function failNextVerifyWith(accountId: string, err: unknown): void {
71
81
  stateFor(accountId).nextVerifyError = err;
72
82
  }
73
83
 
84
+ /** Test helper: every fetch reads this tenant secret first, like a real
85
+ * provider reads its stored credentials; a missing secret is an auth error. */
86
+ export function requireCredentialSecretOnFetch(accountId: string, key: string): void {
87
+ stateFor(accountId).credentialSecretKey = key;
88
+ }
89
+
90
+ async function readCredentialSecret(
91
+ ctx: InboundMailContext,
92
+ account: MailAccountRecord,
93
+ key: string,
94
+ ): Promise<void> {
95
+ const secrets = requireSecretsContext(
96
+ { secrets: ctx.secrets, auditUserId: ctx.auditUserId },
97
+ FEATURE_NAME,
98
+ );
99
+ const secret = await secrets.get(account.tenantId, key);
100
+ if (!secret) throw new InboundAuthError(`${FEATURE_NAME}: no credential secret ${key}`);
101
+ }
102
+
74
103
  /** Test helper: simulates the live connection terminating (the supervisor
75
104
  * must restart with backoff). */
76
105
  export function emitWatchError(accountId: string, err: unknown): void {
@@ -111,13 +140,16 @@ export const inMemoryInboundMailPlugin: InboundMailProviderPlugin = {
111
140
  throw err;
112
141
  }
113
142
  },
114
- fetch: async (_ctx, account, cursor, opts): Promise<InboundFetchResult> => {
143
+ fetch: async (ctx, account, cursor, opts): Promise<InboundFetchResult> => {
115
144
  const state = stateFor(account.id);
116
145
  if (state.nextFetchError) {
117
146
  const err = state.nextFetchError;
118
147
  state.nextFetchError = null;
119
148
  throw err;
120
149
  }
150
+ if (state.credentialSecretKey) {
151
+ await readCredentialSecret(ctx, account, state.credentialSecretKey);
152
+ }
121
153
  const offset = offsetOf(cursor);
122
154
  const slice = state.log.slice(offset, offset + opts.maxMessages);
123
155
  const nextOffset = offset + slice.length;
@@ -7,6 +7,7 @@ export {
7
7
  failNextVerifyWith,
8
8
  INMEMORY_PROVIDER_KEY,
9
9
  isWatching,
10
+ requireCredentialSecretOnFetch,
10
11
  resetInboundInMemory,
11
12
  seedInboundMessage,
12
13
  } from "./feature.ts";