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.
- package/README.md +148 -13
- package/package.json +4 -3
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +77 -8
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/reference/configuration.mdx +12 -4
- package/site/content/docs/reference/environment-variables.mdx +42 -13
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/method-context.ts +12 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
- package/src/drivers/ai-ollama.integration.test.ts +181 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +25 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/index.ts +9 -0
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
- package/src/plugins/auth-methods.security.test.ts +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +84 -14
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
package/src/elements/ai.test.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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"
|
package/src/elements/ai.ts
CHANGED
|
@@ -25,7 +25,12 @@ export type {
|
|
|
25
25
|
AiPromptOptions,
|
|
26
26
|
} from "./ai/declare.ts";
|
|
27
27
|
|
|
28
|
-
export {
|
|
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: "
|
|
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: "
|
|
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 (
|
|
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 (
|
|
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
|
+
});
|
package/src/elements/channel.ts
CHANGED
|
@@ -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";
|
package/src/elements/index.ts
CHANGED
|
@@ -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
|
+
}
|