okengine 0.5.1 → 0.6.1

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 (105) hide show
  1. package/README.md +148 -13
  2. package/package.json +4 -3
  3. package/site/content/docs/elements/ai.mdx +82 -1
  4. package/site/content/docs/elements/channel.mdx +77 -8
  5. package/site/content/docs/elements/flow.mdx +20 -17
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/magic-link.mdx +27 -21
  8. package/site/content/docs/reference/configuration.mdx +12 -4
  9. package/site/content/docs/reference/environment-variables.mdx +42 -13
  10. package/site/content/docs/reference/errors.mdx +14 -0
  11. package/site/content/docs/reference/fx.mdx +68 -16
  12. package/site/content/docs/reference/i18n.mdx +313 -0
  13. package/site/content/docs/reference/index.mdx +6 -1
  14. package/site/content/docs/reference/meta.json +1 -0
  15. package/site/content/docs/reference/plugins.mdx +1 -0
  16. package/src/auth/auth.test.ts +3 -0
  17. package/src/auth/bindings.ts +1 -1
  18. package/src/auth/method-context.ts +12 -2
  19. package/src/cli/openbao-restart.integration.test.ts +106 -97
  20. package/src/compiler/aot.test.ts +16 -13
  21. package/src/compiler/effects-infer.ts +46 -0
  22. package/src/console/server/ai.test.ts +34 -5
  23. package/src/docker/compose.ts +9 -0
  24. package/src/docker/docker.test.ts +39 -0
  25. package/src/docker/dockerfile.integration.test.ts +126 -119
  26. package/src/docker/index.ts +11 -1
  27. package/src/docker/recipes/index.ts +3 -1
  28. package/src/docker/recipes/ollama.ts +43 -0
  29. package/src/docker/stack-id.ts +2 -0
  30. package/src/docker/stack.integration.test.ts +118 -102
  31. package/src/drivers/ai-mock.ts +60 -0
  32. package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
  33. package/src/drivers/ai-ollama.integration.test.ts +181 -0
  34. package/src/drivers/ai-ollama.ts +327 -0
  35. package/src/drivers/ai-openai-compatible.ts +211 -21
  36. package/src/drivers/ai-providers.test.ts +179 -2
  37. package/src/drivers/ai-stream.test.ts +195 -0
  38. package/src/drivers/ai-types.ts +42 -1
  39. package/src/drivers/channel-fcm.ts +49 -53
  40. package/src/drivers/channel-msegat.ts +61 -0
  41. package/src/drivers/channel-sently-map.ts +57 -0
  42. package/src/drivers/channel-sently.test.ts +99 -0
  43. package/src/drivers/channel-smtp.ts +8 -2
  44. package/src/drivers/channel-sndr.ts +28 -0
  45. package/src/drivers/channel-taqnyat.ts +57 -0
  46. package/src/drivers/channel-types.ts +79 -2
  47. package/src/drivers/channel-unifonic.ts +26 -43
  48. package/src/drivers/channel-wa-cloud.ts +33 -47
  49. package/src/drivers/channel-webpush.ts +39 -239
  50. package/src/drivers/index.ts +25 -1
  51. package/src/drivers/ollama.ts +14 -0
  52. package/src/elements/ai/rate.test.ts +53 -0
  53. package/src/elements/ai/rate.ts +66 -0
  54. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  55. package/src/elements/ai/runtime.ts +330 -100
  56. package/src/elements/ai/tools.test.ts +99 -0
  57. package/src/elements/ai.test.ts +26 -2
  58. package/src/elements/ai.ts +10 -1
  59. package/src/elements/channel/costs.test.ts +2 -2
  60. package/src/elements/channel/costs.ts +14 -2
  61. package/src/elements/channel/mime.ts +11 -0
  62. package/src/elements/channel/runtime.ts +94 -0
  63. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  64. package/src/elements/channel.ts +10 -1
  65. package/src/elements/index.ts +9 -0
  66. package/src/i18n/catalogs/ar.ts +67 -0
  67. package/src/i18n/catalogs/en.ts +68 -0
  68. package/src/i18n/failure-message.test.ts +56 -0
  69. package/src/i18n/failure-message.ts +93 -0
  70. package/src/i18n/format.ts +67 -0
  71. package/src/i18n/index.ts +57 -0
  72. package/src/i18n/locale-context.ts +48 -0
  73. package/src/i18n/messages.test.ts +173 -0
  74. package/src/i18n/messages.ts +169 -0
  75. package/src/i18n/types.ts +90 -0
  76. package/src/index.ts +26 -0
  77. package/src/kernel/app.ts +92 -2
  78. package/src/kernel/boot-bind/ai.test.ts +60 -0
  79. package/src/kernel/boot-bind/ai.ts +125 -2
  80. package/src/kernel/boot-bind/channel.test.ts +68 -3
  81. package/src/kernel/boot-bind/channel.ts +93 -2
  82. package/src/kernel/boot.test.ts +4 -3
  83. package/src/kernel/boot.ts +1 -1
  84. package/src/kernel/errors.ts +56 -5
  85. package/src/kernel/fx.test.ts +27 -0
  86. package/src/kernel/fx.ts +74 -18
  87. package/src/kernel/pipeline.test.ts +4 -0
  88. package/src/kernel/pipeline.ts +1 -1
  89. package/src/kernel/plugin.ts +16 -0
  90. package/src/kernel/registry.ts +15 -0
  91. package/src/plugins/auth/shared.ts +5 -1
  92. package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
  93. package/src/plugins/auth-methods.security.test.ts +12 -10
  94. package/src/plugins/email-otp.ts +54 -1
  95. package/src/plugins/index.ts +16 -2
  96. package/src/plugins/magic-link.ts +63 -3
  97. package/src/plugins/username-policy.test.ts +302 -0
  98. package/src/plugins/username.ts +290 -9
  99. package/src/release/exports.test.ts +26 -0
  100. package/src/release/exports.ts +64 -5
  101. package/src/release/index.ts +5 -0
  102. package/src/release/measure.exports.test.ts +13 -1
  103. package/src/release/measure.ts +84 -14
  104. package/src/release/official-plugins.ts +46 -0
  105. package/src/release/readme.test.ts +30 -2
@@ -56,13 +56,22 @@ describe("agent gate denial is recorded", () => {
56
56
 
57
57
  const refund = ai.agent("support", {
58
58
  tools: ["bookings.refundBooking"],
59
- maxSteps: 2,
59
+ maxSteps: 1,
60
+ model: "smart",
60
61
  });
61
62
 
62
63
  const called: string[] = [];
63
64
  const runtime = createAiRuntime({
65
+ models: [ai.model("smart")],
64
66
  agents: [refund],
65
67
  gates,
68
+ defaultDriver: createMockAiDriver({
69
+ "*": {
70
+ __toolCalls: [
71
+ { id: "c1", name: "bookings.refundBooking", arguments: { reason: "customer" } },
72
+ ],
73
+ },
74
+ }),
66
75
  gatesForFlow: (name) => (name === "bookings.refundBooking" ? ["member"] : []),
67
76
  callFlow: async (name) => {
68
77
  called.push(name);
@@ -87,8 +96,16 @@ describe("agent gate denial is recorded", () => {
87
96
  const member = gate.policy("member", ({ auth }) => !!auth.verified);
88
97
  const gates = createGateRuntime({ gates: [member] });
89
98
  const runtime = createAiRuntime({
90
- agents: [ai.agent("support", { tools: ["bookings.getBooking"], maxSteps: 1 })],
99
+ models: [ai.model("smart")],
100
+ agents: [
101
+ ai.agent("support", { tools: ["bookings.getBooking"], maxSteps: 1, model: "smart" }),
102
+ ],
91
103
  gates,
104
+ defaultDriver: createMockAiDriver({
105
+ "*": {
106
+ __toolCalls: [{ id: "c1", name: "bookings.getBooking", arguments: {} }],
107
+ },
108
+ }),
92
109
  gatesForFlow: () => ["member"],
93
110
  callFlow: async () => ({ booking: "B1" }),
94
111
  });
@@ -367,13 +384,20 @@ describe("agent tool trail carries effects; denials are not errors", () => {
367
384
  const member = gate.policy("member", ({ auth }) => !!auth.verified);
368
385
  const gates = createGateRuntime({ gates: [member] });
369
386
  const runtime = createAiRuntime({
387
+ models: [ai.model("smart")],
370
388
  agents: [
371
389
  ai.agent("support", {
372
390
  tools: ["bookings.refundBooking"],
373
391
  maxSteps: 1,
392
+ model: "smart",
374
393
  }),
375
394
  ],
376
395
  gates,
396
+ defaultDriver: createMockAiDriver({
397
+ "*": {
398
+ __toolCalls: [{ id: "c1", name: "bookings.refundBooking", arguments: {} }],
399
+ },
400
+ }),
377
401
  gatesForFlow: () => ["member"],
378
402
  effectsForFlow: (name) =>
379
403
  name === "bookings.refundBooking"
@@ -25,7 +25,12 @@ export type {
25
25
  AiPromptOptions,
26
26
  } from "./ai/declare.ts";
27
27
 
28
- export { createAiRuntime, AiSchemaValidationError } from "./ai/runtime.ts";
28
+ export {
29
+ createAiRuntime,
30
+ AiSchemaValidationError,
31
+ promptContentFromInput,
32
+ AI_DEFAULT_MAX_STEPS,
33
+ } from "./ai/runtime.ts";
29
34
  export type {
30
35
  AgentDenial,
31
36
  AgentRunRecord,
@@ -38,9 +43,13 @@ export type {
38
43
  AiJournalEntry,
39
44
  AiRuntime,
40
45
  AiSchemaMismatch,
46
+ AiStreamOptions,
41
47
  CreateAiRuntimeOptions,
42
48
  } from "./ai/runtime.ts";
43
49
 
50
+ export { AI_RATE_PRESETS, aiRateGate, createAiRateGates } from "./ai/rate.ts";
51
+ export type { AiRatePreset } from "./ai/rate.ts";
52
+
44
53
  export { assertAllowPiiForAsk, AiPiiBuildError, type PiiAskCheckInput } from "./ai/pii.ts";
45
54
 
46
55
  export {
@@ -18,7 +18,7 @@ describe("fallbackWeeklyCostDelta", () => {
18
18
  status: "fallback",
19
19
  attempts: [
20
20
  { driverId: "wa-cloud", ok: false, at: weekStart + 1 },
21
- { driverId: "unifonic", ok: true, at: weekStart + 2 },
21
+ { driverId: "taqnyat", ok: true, at: weekStart + 2 },
22
22
  ],
23
23
  at: weekStart + 3,
24
24
  },
@@ -39,7 +39,7 @@ describe("fallbackWeeklyCostDelta", () => {
39
39
  status: "fallback",
40
40
  attempts: [
41
41
  { driverId: "wa-cloud", ok: false, at: weekStart + 6 },
42
- { driverId: "unifonic", ok: true, at: weekStart + 7 },
42
+ { driverId: "msegat", ok: true, at: weekStart + 7 },
43
43
  ],
44
44
  at: weekStart + 8,
45
45
  },
@@ -106,13 +106,25 @@ export function fallbackWeeklyCostDelta(
106
106
  function mediumFromDriver(driverId: string, receiptMedium: string): string {
107
107
  const id = driverId.toLowerCase();
108
108
  if (id.includes("wa") || id.includes("whatsapp")) return "whatsapp";
109
- if (id.includes("sms") || id.includes("unifonic") || id.includes("twilio")) {
109
+ if (
110
+ id.includes("sms") ||
111
+ id.includes("taqnyat") ||
112
+ id.includes("msegat") ||
113
+ id.includes("unifonic") ||
114
+ id.includes("twilio")
115
+ ) {
110
116
  return "sms";
111
117
  }
112
118
  if (id.includes("push") || id.includes("fcm") || id.includes("webpush")) {
113
119
  return "push";
114
120
  }
115
- if (id.includes("smtp") || id.includes("resend") || id.includes("ses") || id.includes("email")) {
121
+ if (
122
+ id.includes("smtp") ||
123
+ id.includes("resend") ||
124
+ id.includes("sndr") ||
125
+ id.includes("ses") ||
126
+ id.includes("email")
127
+ ) {
116
128
  return "email";
117
129
  }
118
130
  return receiptMedium === "any" ? "email" : receiptMedium;
@@ -11,3 +11,14 @@ export type { Attachment, MailOptions, SendResult, Transport, RetryConfig } from
11
11
  export { SentlyError } from "sently/errors";
12
12
  export { RetryTransport } from "sently/transports/retry";
13
13
  export { FallbackTransport, FallbackError, type FallbackAttempt } from "sently/transports/fallback";
14
+ export {
15
+ toChannelSendResult,
16
+ type AnySendResult,
17
+ type ChannelSendResult as SentlyChannelSendResult,
18
+ } from "sently/channel-result";
19
+ export {
20
+ parse as parseSndrWebhook,
21
+ verifySignature as verifySndrSignature,
22
+ } from "sently/webhooks/sndr";
23
+ export { parse as parseUnifonicWebhook } from "sently/webhooks/unifonic";
24
+ export { toDeliveryEvent, type EmailEvent, type DeliveryEvent } from "sently/webhooks";
@@ -246,10 +246,104 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
246
246
  }
247
247
  }
248
248
 
249
+ async function sendViaSmsFallback(
250
+ chain: ChannelDriver[],
251
+ message: ChannelMessage,
252
+ ): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] } | undefined> {
253
+ const sms = chain
254
+ .map((d) => (d.smsTransport ? { driver: d, transport: d.smsTransport } : undefined))
255
+ .filter(
256
+ (
257
+ x,
258
+ ): x is { driver: ChannelDriver; transport: NonNullable<ChannelDriver["smsTransport"]> } =>
259
+ !!x,
260
+ );
261
+ if (sms.length === 0) return undefined;
262
+
263
+ const attempts: ChannelAttempt[] = [];
264
+ const transports = sms.map(({ transport }) =>
265
+ options.retry ? new RetryTransport(transport) : transport,
266
+ );
267
+ const fallback = new FallbackTransport(transports, {
268
+ onFallback(failedIndex, error) {
269
+ const provider =
270
+ transports[failedIndex]?.provider ?? sms[failedIndex]?.driver.id ?? `sms-${failedIndex}`;
271
+ attempts.push({
272
+ driverId: provider,
273
+ ok: false,
274
+ error: error instanceof Error ? error.message : String(error),
275
+ at: now(),
276
+ });
277
+ },
278
+ });
279
+
280
+ const body = {
281
+ to: message.to,
282
+ body: message.text ?? String(message.data?.code ?? ""),
283
+ ...(message.from ? { from: message.from } : {}),
284
+ };
285
+
286
+ try {
287
+ const sendResult = await fallback.send(body);
288
+ const driverId =
289
+ sendResult.provider ??
290
+ transports[sendResult.providerIndex ?? 0]?.provider ??
291
+ sms[0]?.driver.id ??
292
+ "sms";
293
+ attempts.push({
294
+ driverId,
295
+ ok: true,
296
+ at: now(),
297
+ messageId: sendResult.messageId,
298
+ });
299
+ return {
300
+ result: {
301
+ ok: true,
302
+ messageId: sendResult.messageId,
303
+ driverId,
304
+ attempts,
305
+ },
306
+ attempts,
307
+ };
308
+ } catch (err) {
309
+ const fbAttempts =
310
+ err &&
311
+ typeof err === "object" &&
312
+ "attempts" in err &&
313
+ Array.isArray((err as { attempts: FallbackAttempt[] }).attempts)
314
+ ? (err as { attempts: FallbackAttempt[] }).attempts
315
+ : [];
316
+ for (const a of fbAttempts) {
317
+ if (!attempts.some((x) => x.driverId === a.provider && !x.ok)) {
318
+ attempts.push({
319
+ driverId: a.provider,
320
+ ok: false,
321
+ error: a.error instanceof Error ? a.error.message : String(a.error),
322
+ at: now(),
323
+ });
324
+ }
325
+ }
326
+ return {
327
+ result: {
328
+ ok: false,
329
+ messageId: crypto.randomUUID(),
330
+ driverId: "fallback",
331
+ attempts,
332
+ },
333
+ attempts,
334
+ };
335
+ }
336
+ }
337
+
249
338
  async function sendViaChannelChain(
250
339
  chain: ChannelDriver[],
251
340
  message: ChannelMessage,
252
341
  ): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] }> {
342
+ if (message.medium === "sms") {
343
+ const viaSms = await sendViaSmsFallback(chain, message);
344
+ if (viaSms) return viaSms;
345
+ }
346
+
253
347
  const attempts: ChannelAttempt[] = [];
254
348
  for (const d of chain) {
255
349
  if (!d.channel) continue;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SNDR webhook helpers re-exported from sently.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { parseSndrWebhook } from "./mime.ts";
7
+
8
+ describe("parseSndrWebhook", () => {
9
+ test("normalizes a delivery event payload", () => {
10
+ const events = parseSndrWebhook({
11
+ type: "email.delivered",
12
+ data: {
13
+ email_id: "em_test",
14
+ to: ["user@example.com"],
15
+ },
16
+ });
17
+ expect(events).toEqual([
18
+ expect.objectContaining({
19
+ provider: "sndr",
20
+ type: "delivered",
21
+ messageId: "em_test",
22
+ recipient: "user@example.com",
23
+ }),
24
+ ]);
25
+ });
26
+ });
@@ -2,7 +2,7 @@
2
2
  * Channel element — reaching humans.
3
3
  *
4
4
  * Physics: email · SMS · WhatsApp · push.
5
- * Drivers: `console` · `smtp` · `resend` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
5
+ * Drivers: `console` · `smtp` · `resend` · `sndr` · `taqnyat` · `msegat` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
6
6
  *
7
7
  * Transport interface is identical to sently's so its transports run unchanged.
8
8
  * MIME, attachments, retry, and the unified error hierarchy come from sently.
@@ -91,9 +91,18 @@ export {
91
91
  RetryTransport,
92
92
  FallbackTransport,
93
93
  FallbackError,
94
+ toChannelSendResult,
95
+ toDeliveryEvent,
96
+ parseSndrWebhook,
97
+ verifySndrSignature,
98
+ parseUnifonicWebhook,
94
99
  type Attachment,
95
100
  type MailOptions,
96
101
  type SendResult,
97
102
  type Transport,
98
103
  type FallbackAttempt,
104
+ type EmailEvent,
105
+ type DeliveryEvent,
106
+ type AnySendResult,
107
+ type SentlyChannelSendResult,
99
108
  } from "./channel/mime.ts";
@@ -132,6 +132,11 @@ export {
132
132
  RetryTransport,
133
133
  FallbackTransport,
134
134
  FallbackError,
135
+ parseSndrWebhook,
136
+ verifySndrSignature,
137
+ parseUnifonicWebhook,
138
+ toChannelSendResult,
139
+ toDeliveryEvent,
135
140
  } from "./channel.ts";
136
141
  export type {
137
142
  ChannelTemplateDecl,
@@ -150,6 +155,10 @@ export type {
150
155
  Transport,
151
156
  MediumCosts,
152
157
  EmailAuthResult,
158
+ EmailEvent,
159
+ DeliveryEvent,
160
+ AnySendResult,
161
+ SentlyChannelSendResult,
153
162
  } from "./channel.ts";
154
163
 
155
164
  export {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Built-in Arabic messages — framework OKE codes + typed failure messages.
3
+ */
4
+
5
+ export const builtinAr = {
6
+ oke: {
7
+ "1001": {
8
+ cause: 'التدفق "{flow}" يقرأ "{resource}" دون الإعلان عنه.',
9
+ fix: 'أضف "{resource}" إلى effects.reads لهذا التدفق.',
10
+ },
11
+ "1002": {
12
+ cause: 'التدفق "{flow}" يكتب "{resource}" دون الإعلان عنه.',
13
+ fix: 'أضف "{resource}" إلى effects.writes لهذا التدفق.',
14
+ },
15
+ "1003": {
16
+ cause: 'التدفق "{flow}" يُصدِر "{resource}" دون الإعلان عنه.',
17
+ fix: 'أضف "{resource}" إلى effects.emits لهذا التدفق.',
18
+ },
19
+ "1004": {
20
+ cause: 'التدفق "{flow}" يرسل "{resource}" دون الإعلان عنه.',
21
+ fix: 'أضف "{resource}" إلى effects.sends لهذا التدفق.',
22
+ },
23
+ "1005": {
24
+ cause: 'التدفق "{flow}" يستدعي النموذج "{resource}" دون الإعلان عنه.',
25
+ fix: 'أضف "{resource}" إلى effects.asks لهذا التدفق.',
26
+ },
27
+ "1006": {
28
+ cause: 'التدفق "{flow}" يقرأ السر "{resource}" دون الإعلان عنه.',
29
+ fix: 'أضف "{resource}" إلى effects.secrets لهذا التدفق.',
30
+ },
31
+ "1007": {
32
+ cause: 'التدفق "{flow}" يستدعي التدفق "{resource}" دون الإعلان عنه.',
33
+ fix: 'أضف "{resource}" إلى effects.calls لهذا التدفق.',
34
+ },
35
+ "1042": {
36
+ cause: 'التدفق "{flow}" يُصدِر الإشارة "{resource}" بلا مشترك.',
37
+ fix: "أضف on({resource}, …) أو عيّن الإشارة '{'optional: true'}'.",
38
+ },
39
+ "1101": {
40
+ cause: "جدول النطاق غير موجود — لم تُطبَّق الترحيلات.",
41
+ fix: "شغّل `oke db migrate` على هذه البيئة.",
42
+ },
43
+ },
44
+ errors: {
45
+ Unauthorized: "المصادقة مطلوبة.",
46
+ Forbidden: "غير مسموح لك بتنفيذ هذا الإجراء.",
47
+ RateLimited: "طلبات كثيرة جداً. حاول لاحقاً.",
48
+ ValidationError: "فشل التحقق من الطلب.",
49
+ NotFound: "المورد المطلوب غير موجود.",
50
+ AuthFailed: "فشلت المصادقة.",
51
+ AuthRateLimited: "محاولات مصادقة كثيرة جداً. حاول لاحقاً.",
52
+ "AuthFailed.invalid_credentials": "بيانات الاعتماد غير صحيحة.",
53
+ "AuthFailed.invalid_refresh": "رمز التحديث غير صالح أو منتهٍ.",
54
+ "AuthFailed.invalid_email": "أدخل بريداً إلكترونياً صالحاً.",
55
+ "AuthFailed.invalid_phone": "أدخل رقم هاتف صالحاً.",
56
+ "AuthFailed.unauthenticated": "سجّل الدخول للمتابعة.",
57
+ "AuthFailed.password_breached": "اختر كلمة مرور أخرى — هذه تظهر في قائمة اختراق.",
58
+ "AuthFailed.username_policy": "اسم المستخدم لا يستوفي متطلبات السياسة.",
59
+ "AuthFailed.password_policy": "كلمة المرور لا تستوفي متطلبات السياسة.",
60
+ "AuthFailed.invalid_origin": "تم رفض أصل مفتاح المرور.",
61
+ "Forbidden.csrf": "تم حظر الطلب عبر المواقع.",
62
+ "Forbidden.ip_denied": "عنوان IP الخاص بك محظور.",
63
+ "Forbidden.ip_not_allowed": "عنوان IP الخاص بك غير مسموح.",
64
+ "Forbidden.policy_denied": "رفضت السياسة هذا الطلب.",
65
+ "AuthRateLimited.rate_limited": "محاولات مصادقة كثيرة جداً. حاول لاحقاً.",
66
+ },
67
+ } as const;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Built-in English messages — framework OKE codes + typed failure messages.
3
+ */
4
+
5
+ export const builtinEn = {
6
+ oke: {
7
+ "1001": {
8
+ cause: 'Flow "{flow}" reads "{resource}" without declaring it.',
9
+ fix: "Add \"{resource}\" to this flow''s effects.reads.",
10
+ },
11
+ "1002": {
12
+ cause: 'Flow "{flow}" writes "{resource}" without declaring it.',
13
+ fix: "Add \"{resource}\" to this flow''s effects.writes.",
14
+ },
15
+ "1003": {
16
+ cause: 'Flow "{flow}" emits "{resource}" without declaring it.',
17
+ fix: "Add \"{resource}\" to this flow''s effects.emits.",
18
+ },
19
+ "1004": {
20
+ cause: 'Flow "{flow}" sends "{resource}" without declaring it.',
21
+ fix: "Add \"{resource}\" to this flow''s effects.sends.",
22
+ },
23
+ "1005": {
24
+ cause: 'Flow "{flow}" asks "{resource}" without declaring it.',
25
+ fix: "Add \"{resource}\" to this flow''s effects.asks.",
26
+ },
27
+ "1006": {
28
+ cause: 'Flow "{flow}" reads secret "{resource}" without declaring it.',
29
+ fix: "Add \"{resource}\" to this flow''s effects.secrets.",
30
+ },
31
+ "1007": {
32
+ cause: 'Flow "{flow}" calls "{resource}" without declaring it.',
33
+ fix: "Add \"{resource}\" to this flow''s effects.calls.",
34
+ },
35
+ "1042": {
36
+ cause: 'Flow "{flow}" emits signal "{resource}" with no subscriber.',
37
+ fix: "Add on({resource}, …) or mark the signal '{'optional: true'}'.",
38
+ },
39
+ "1101": {
40
+ cause: "domain table not found — migrations have not been applied.",
41
+ fix: "run `oke db migrate` against this environment.",
42
+ },
43
+ },
44
+ errors: {
45
+ Unauthorized: "Authentication required.",
46
+ Forbidden: "You are not allowed to perform this action.",
47
+ RateLimited: "Too many requests. Try again later.",
48
+ ValidationError: "The request failed validation.",
49
+ NotFound: "The requested resource was not found.",
50
+ AuthFailed: "Authentication failed.",
51
+ AuthRateLimited: "Too many authentication attempts. Try again later.",
52
+ "AuthFailed.invalid_credentials": "Invalid credentials.",
53
+ "AuthFailed.invalid_refresh": "Refresh token is invalid or expired.",
54
+ "AuthFailed.invalid_email": "Enter a valid email address.",
55
+ "AuthFailed.invalid_phone": "Enter a valid phone number.",
56
+ "AuthFailed.unauthenticated": "Sign in to continue.",
57
+ "AuthFailed.password_breached":
58
+ "Choose a different password — this one appears in a breach list.",
59
+ "AuthFailed.username_policy": "That username does not meet the policy requirements.",
60
+ "AuthFailed.password_policy": "That password does not meet the policy requirements.",
61
+ "AuthFailed.invalid_origin": "Passkey origin was rejected.",
62
+ "Forbidden.csrf": "Cross-site request blocked.",
63
+ "Forbidden.ip_denied": "Your IP address is blocked.",
64
+ "Forbidden.ip_not_allowed": "Your IP address is not allowed.",
65
+ "Forbidden.policy_denied": "Policy denied this request.",
66
+ "AuthRateLimited.rate_limited": "Too many authentication attempts. Try again later.",
67
+ },
68
+ } as const;
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fail } from "../kernel/errors.ts";
3
+ import { OKE_ERRORS, OkeError } from "../kernel/errors.ts";
4
+ import { catalogReasonKey, resolveFailureMessage } from "./failure-message.ts";
5
+ import { runWithLocale } from "./locale-context.ts";
6
+
7
+ describe("catalogReasonKey", () => {
8
+ test("keeps identifiers; slugifies free text", () => {
9
+ expect(catalogReasonKey("invalid_credentials")).toBe("invalid_credentials");
10
+ expect(catalogReasonKey("policy denied")).toBe("policy_denied");
11
+ });
12
+ });
13
+
14
+ describe("resolveFailureMessage", () => {
15
+ test("resolves reason-specific then code-level keys", () => {
16
+ expect(resolveFailureMessage("AuthFailed", { reason: "invalid_credentials" }, "en")).toBe(
17
+ "Invalid credentials.",
18
+ );
19
+ expect(resolveFailureMessage("AuthFailed", { reason: "invalid_credentials" }, "ar")).toBe(
20
+ "بيانات الاعتماد غير صحيحة.",
21
+ );
22
+ expect(resolveFailureMessage("Unauthorized", {}, "ar")).toBe("المصادقة مطلوبة.");
23
+ expect(resolveFailureMessage("FlightFull", { seats: 0 }, "en")).toBeUndefined();
24
+ });
25
+ });
26
+
27
+ describe("fail — auto message", () => {
28
+ test("attaches localized message from active locale", () => {
29
+ const en = runWithLocale({ locale: "en", defaultLocale: "en" }, () =>
30
+ fail("AuthFailed", { reason: "invalid_email" }),
31
+ );
32
+ expect(en.error.message).toBe("Enter a valid email address.");
33
+
34
+ const ar = runWithLocale({ locale: "ar", defaultLocale: "en" }, () => fail("NotFound", {}));
35
+ expect(ar.error.message).toBe("المورد المطلوب غير موجود.");
36
+ });
37
+
38
+ test("explicit message wins", () => {
39
+ const r = fail("AuthFailed", { reason: "invalid_email" }, { message: "custom" });
40
+ expect(r.error.message).toBe("custom");
41
+ });
42
+ });
43
+
44
+ describe("OkeError — localized cause/fix", () => {
45
+ test("uses Arabic catalog when locale is ar", () => {
46
+ const err = new OkeError(
47
+ OKE_ERRORS.UNDECLARED_READ,
48
+ { flow: "notes.create", resource: "sql:notes" },
49
+ "ar",
50
+ );
51
+ expect(err.causeText).toContain("notes.create");
52
+ expect(err.causeText).toContain("sql:notes");
53
+ expect(err.causeText).toContain("يقرأ");
54
+ expect(err.fix).toContain("effects.reads");
55
+ });
56
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Resolve a localized `error.message` for typed flow failures.
3
+ */
4
+
5
+ import { getActiveDefaultLocale, getActiveLocale } from "./locale-context.ts";
6
+ import { getMessageCatalogs, translate } from "./messages.ts";
7
+ import type { MessageValues } from "./types.ts";
8
+
9
+ /**
10
+ * Whether a reason string is safe as a catalog key segment.
11
+ *
12
+ * @param reason - Failure `data.reason`
13
+ */
14
+ export function isCatalogReason(reason: string): boolean {
15
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(reason);
16
+ }
17
+
18
+ /**
19
+ * Normalize a free-text reason into a catalog key segment.
20
+ *
21
+ * @param reason - Raw reason
22
+ */
23
+ export function catalogReasonKey(reason: string): string {
24
+ if (isCatalogReason(reason)) return reason;
25
+ return reason
26
+ .trim()
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, "_")
29
+ .replace(/^_|_$/g, "");
30
+ }
31
+
32
+ /**
33
+ * Build ICU values from failure data (string/number/boolean leaves only).
34
+ *
35
+ * @param data - Failure payload
36
+ */
37
+ export function failureMessageValues(data: unknown): MessageValues | undefined {
38
+ if (data === null || data === undefined || typeof data !== "object") return undefined;
39
+ const out: Record<string, string | number | boolean> = {};
40
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
41
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
42
+ out[key] = value;
43
+ }
44
+ }
45
+ return Object.keys(out).length > 0 ? out : undefined;
46
+ }
47
+
48
+ /**
49
+ * Resolve a localized message for a typed failure, or `undefined` when no
50
+ * built-in / app catalog entry exists (custom app codes stay message-less).
51
+ *
52
+ * Lookup order: `errors.{code}.{reason}` → `errors.{code}`.
53
+ *
54
+ * @param code - Failure code (`AuthFailed`, `Unauthorized`, …)
55
+ * @param data - Failure data (may include `reason`)
56
+ * @param locale - Override locale (defaults to active request locale)
57
+ */
58
+ export function resolveFailureMessage(
59
+ code: string,
60
+ data?: unknown,
61
+ locale?: string,
62
+ ): string | undefined {
63
+ const catalogs = getMessageCatalogs();
64
+ const activeLocale = locale ?? getActiveLocale("en");
65
+ const defaultLocale = getActiveDefaultLocale("en");
66
+ const values = failureMessageValues(data);
67
+ const reason =
68
+ data !== null &&
69
+ data !== undefined &&
70
+ typeof data === "object" &&
71
+ "reason" in data &&
72
+ typeof (data as { reason: unknown }).reason === "string"
73
+ ? catalogReasonKey((data as { reason: string }).reason)
74
+ : undefined;
75
+
76
+ const keys: string[] = [];
77
+ if (reason) keys.push(`errors.${code}.${reason}`);
78
+ keys.push(`errors.${code}`);
79
+
80
+ for (const key of keys) {
81
+ const primary = catalogs[activeLocale]?.[key];
82
+ const fallback = catalogs[defaultLocale]?.[key];
83
+ if (primary === undefined && fallback === undefined) continue;
84
+ return translate({
85
+ locale: activeLocale,
86
+ defaultLocale,
87
+ catalogs,
88
+ key,
89
+ values,
90
+ });
91
+ }
92
+ return undefined;
93
+ }