@cosmicdrift/kumiko-samples 0.387.0 → 0.388.0
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 +1 -1
- package/packages/bundled-features/package.json +8 -1
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
- package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
- package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
- package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
- package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
- package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
- package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
- package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
- package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
- package/packages/bundled-features/src/cap-counter/changes.json +18 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/changes.json +12 -0
- package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
- package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
- package/packages/bundled-features/src/config/write-helpers.ts +27 -8
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
- package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
- package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
- package/packages/bundled-features/src/file-foundation/index.ts +1 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/form-draft/changes.json +6 -0
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
- package/packages/bundled-features/src/host-routing/README.md +1 -1
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
- package/packages/bundled-features/src/host-routing/options.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
- package/packages/bundled-features/src/jobs/changes.json +13 -0
- package/packages/bundled-features/src/jobs/feature.ts +14 -4
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
- package/packages/bundled-features/src/legal-pages/README.md +1 -1
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
- package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
- package/packages/bundled-features/src/sessions/feature.ts +7 -2
- package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
- package/packages/bundled-features/src/shared/index.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
- package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
- package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
- package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
- package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
- package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
- package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
- package/packages/bundled-features/src/template-resolver/api.ts +31 -4
- package/packages/bundled-features/src/template-resolver/changes.json +48 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
- package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
- package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
- package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
- package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
- package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
- package/packages/bundled-features/src/waitlist/import.ts +5 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
- package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
- package/packages/bundled-features/src/waitlist/options.ts +3 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
- package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
- package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
- package/samples/recipes/auth-mfa/README.md +5 -0
- package/samples/recipes/auth-mfa/package.json +5 -2
- package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
- package/samples/recipes/auth-mfa/tsconfig.json +8 -0
- package/samples/recipes/env-composition/README.md +3 -1
- package/samples/recipes/env-composition/src/env.ts +2 -0
- package/samples/recipes/legal-pages/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +18 -0
- package/samples/recipes/rate-limiting/src/feature.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Undecryptable mail account no longer stops polling of other accounts",
|
|
6
|
+
"detail": "Any decryption failure of an account address (corrupt envelope, missing key, AAD mismatch) is logged by error class and the account is skipped; the other accounts keep polling."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.388.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Rename audit reason comes from the caller role",
|
|
12
|
+
"detail": "update-account records tenant_admin or system_admin from the caller's roles and honors a client-supplied reason only for the system identity, so a form call can no longer write system reasons such as watch_supervisor. The rename form no longer carries a reason field and the reason payload field is optional."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.388.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Seen-message lookup handles listings of any size",
|
|
18
|
+
"detail": "filterUnknownMessageIds queries the already seen provider message ids in chunks of 1000, so a full-mailbox listing no longer exceeds the Postgres bind parameter limit and aborts the account's poll."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.378.0",
|
|
4
22
|
"type": "fix",
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// the account after fixing its credentials. Full-snapshot event: loads the
|
|
4
4
|
// latest state, merges the delta fields, and appends the new snapshot.
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
7
|
+
import {
|
|
8
|
+
type SessionUser,
|
|
9
|
+
SYSTEM_USER_ID,
|
|
10
|
+
type WriteHandlerDef,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
12
|
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
13
|
import * as z from "zod";
|
|
9
14
|
import { InboundMailAccountStatuses } from "../constants.ts";
|
|
@@ -27,12 +32,19 @@ export const updateAccountSchema = z.object({
|
|
|
27
32
|
/** Ops-Signal ("idle", "watching", "backoff:3", ...). */
|
|
28
33
|
watchState: z.string().max(100).optional(),
|
|
29
34
|
displayName: z.string().max(200).optional(),
|
|
30
|
-
/** Audit reason for metadata.headers
|
|
31
|
-
* "
|
|
32
|
-
reason: z.string().min(1).max(100),
|
|
35
|
+
/** Audit reason for metadata.headers, honored for the system identity only
|
|
36
|
+
* ("watch_supervisor", "oauth_refresh"); user callers get a role-derived reason. */
|
|
37
|
+
reason: z.string().min(1).max(100).optional(),
|
|
33
38
|
});
|
|
34
39
|
type UpdateAccountPayload = z.infer<typeof updateAccountSchema>;
|
|
35
40
|
|
|
41
|
+
const SYSTEM_FALLBACK_REASON = "system";
|
|
42
|
+
|
|
43
|
+
function auditReasonFor(user: SessionUser, requestedReason: string | undefined): string {
|
|
44
|
+
if (user.id === SYSTEM_USER_ID) return requestedReason ?? SYSTEM_FALLBACK_REASON;
|
|
45
|
+
return user.roles.includes(ROLES.SystemAdmin) ? "system_admin" : "tenant_admin";
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export const updateAccountHandler: WriteHandlerDef = {
|
|
37
49
|
name: "update-account",
|
|
38
50
|
schema: updateAccountSchema,
|
|
@@ -65,7 +77,7 @@ export const updateAccountHandler: WriteHandlerDef = {
|
|
|
65
77
|
};
|
|
66
78
|
const headers: MailAccountEventHeaders = {
|
|
67
79
|
providerName: current.provider,
|
|
68
|
-
reason: payload.reason,
|
|
80
|
+
reason: auditReasonFor(event.user, payload.reason),
|
|
69
81
|
};
|
|
70
82
|
await ctx.unsafeAppendEvent({
|
|
71
83
|
aggregateId: payload.accountId,
|
|
@@ -77,8 +77,6 @@ export const mailAccountListScreen: ProjectionListScreenDefinition = {
|
|
|
77
77
|
dormant: true,
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
-
// The reason is the audit label update-account requires; it is not in the
|
|
81
|
-
// layout, so the default is what gets written.
|
|
82
80
|
export const mailAccountEditScreen: ActionFormScreenDefinition = {
|
|
83
81
|
id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
84
82
|
type: "actionForm",
|
|
@@ -87,7 +85,6 @@ export const mailAccountEditScreen: ActionFormScreenDefinition = {
|
|
|
87
85
|
fields: {
|
|
88
86
|
accountId: { type: "text", required: true },
|
|
89
87
|
displayName: { type: "text", required: true, maxLength: 200 },
|
|
90
|
-
reason: { type: "text", default: "tenant_admin" },
|
|
91
88
|
},
|
|
92
89
|
layout: {
|
|
93
90
|
sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
|
|
@@ -21,8 +21,9 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
|
|
|
21
21
|
* config — tenant config reads (host/port/... of the plugins)
|
|
22
22
|
* registry — extension lookup in the factory (not plugin-internal)
|
|
23
23
|
* secrets — tenant secret reads (IMAP password, OAuth refresh token)
|
|
24
|
-
* auditUserId — audit identity for secret reads. Handler path:
|
|
25
|
-
*
|
|
24
|
+
* auditUserId — audit identity for secret reads. Handler path: unset,
|
|
25
|
+
* requireSecretsContext falls back to ctx.user.id; worker/provider
|
|
26
|
+
* path: system identity set by the resolver.
|
|
26
27
|
* metricsFor — metrics handle of a feature by name, same behavior as
|
|
27
28
|
* HandlerContext.metricsFor: unregistered metrics are a silent
|
|
28
29
|
* no-op instead of a throw.
|
|
@@ -172,7 +173,7 @@ export type InboundMailProviderPlugin = {
|
|
|
172
173
|
* for password accounts and providers without an oauth flow. */
|
|
173
174
|
readonly accessToken?: string;
|
|
174
175
|
/** Batch lookup of the provider message ids not yet ingested for this
|
|
175
|
-
* account (committed seen anchors,
|
|
176
|
+
* account (committed seen anchors; lists of any size are fine, queried in chunks). Call it after
|
|
176
177
|
* the listing/delta page and download MIME only for the returned ids.
|
|
177
178
|
* Ingest dedup stays the correctness anchor; this only saves downloads. */
|
|
178
179
|
readonly filterUnknownMessageIds?: (
|
|
@@ -88,6 +88,8 @@ const DEFAULT_WATCH_LEASE_TTL_SECONDS = 90;
|
|
|
88
88
|
* can add further scopes later without a schema change. */
|
|
89
89
|
const CURSOR_SCOPE = "default";
|
|
90
90
|
|
|
91
|
+
const SEEN_LOOKUP_CHUNK_SIZE = 1000;
|
|
92
|
+
|
|
91
93
|
export type InboundMailSupervisorDeps = {
|
|
92
94
|
/** Slim context for provider calls (`registry` is required; `secrets` is
|
|
93
95
|
* used for provider credential reads). */
|
|
@@ -223,11 +225,19 @@ export function createInboundMailSupervisor(
|
|
|
223
225
|
const piiKms = configuredPiiSubjectKms();
|
|
224
226
|
const records: MailAccountRecord[] = [];
|
|
225
227
|
for (const row of rows) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
let decrypted: Record<string, unknown>;
|
|
229
|
+
try {
|
|
230
|
+
decrypted = piiKms
|
|
231
|
+
? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
|
|
232
|
+
requestId: "inbound-mail-foundation:supervisor:list-accounts",
|
|
233
|
+
})
|
|
234
|
+
: row;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// Class name only: the message may carry ciphertext or key details.
|
|
237
|
+
const errorName = error instanceof Error ? error.name : "unknown error";
|
|
238
|
+
log(`inbound-mail: account ${row.id} skipped — address did not decrypt (${errorName})`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
231
241
|
const decryptedAddress = decrypted["address"];
|
|
232
242
|
// Falling back to row.address would hand the ciphertext to a provider.
|
|
233
243
|
// One undecryptable account must not stop polling of every other tenant's accounts.
|
|
@@ -400,17 +410,23 @@ export function createInboundMailSupervisor(
|
|
|
400
410
|
function unknownMessageIdsFilterFor(account: MailAccountRecord) {
|
|
401
411
|
return async (providerMessageIds: readonly string[]): Promise<ReadonlySet<string>> => {
|
|
402
412
|
if (providerMessageIds.length === 0) return new Set();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
{
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
413
|
+
// One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
|
|
414
|
+
const seenIds = new Set<string>();
|
|
415
|
+
for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
|
|
416
|
+
const seen = await selectMany<{ providerMessageId: string }>(
|
|
417
|
+
deps.db,
|
|
418
|
+
seenMessageTable,
|
|
419
|
+
{
|
|
420
|
+
tenantId: account.tenantId,
|
|
421
|
+
accountId: account.id,
|
|
422
|
+
providerMessageId: {
|
|
423
|
+
in: providerMessageIds.slice(start, start + SEEN_LOOKUP_CHUNK_SIZE),
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
{ fields: ["providerMessageId"] },
|
|
427
|
+
);
|
|
428
|
+
for (const row of seen) seenIds.add(row.providerMessageId);
|
|
429
|
+
}
|
|
414
430
|
return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
|
|
415
431
|
};
|
|
416
432
|
}
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "readRoles for tenant job status",
|
|
6
|
+
"detail": "`createJobsFeature({ readRoles: [\"PropertyManager\"] })` lets those app roles call `jobs:query:failures` and `jobs:query:tenant-runs` for their own tenant. They are added to the default `User`, `Editor` and admin roles, which stay. Before, a member who only holds app roles got access_denied.",
|
|
7
|
+
"migration": "Optional. Apps whose members hold only their own roles pass `createJobsFeature({ readRoles: [...] })`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.388.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "Deduplicated job add keeps the finished tenant run row",
|
|
13
|
+
"detail": "JobQueuedMeta carries the BullMQ creation time of the job. The queued hook only removes finished rows queued before that time (leftovers of a Redis reset), so re-announcing an already finished job no longer turns its row back into queued."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.384.0",
|
|
4
17
|
"type": "improvement",
|
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
createStaleRunSweepJob,
|
|
24
24
|
DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS,
|
|
25
25
|
} from "./handlers/stale-run-sweep.job.ts";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { createTenantFailuresQuery } from "./handlers/tenant-failures.query.ts";
|
|
27
|
+
import { createTenantRunsQuery } from "./handlers/tenant-runs.query.ts";
|
|
28
28
|
import { triggerWrite } from "./handlers/trigger.write.ts";
|
|
29
29
|
import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.ts";
|
|
30
30
|
import {
|
|
@@ -49,11 +49,21 @@ export type JobsFeatureOptions = {
|
|
|
49
49
|
// it). Must clear any legitimately long-running job (reindexEntity,
|
|
50
50
|
// projectionRebuild) — set high, not tight.
|
|
51
51
|
readonly staleRunTimeoutHours?: number;
|
|
52
|
+
// Additional app roles that may read their own tenant's job status and
|
|
53
|
+
// failures (jobs:query:failures, jobs:query:tenant-runs). Added to the default
|
|
54
|
+
// User/Editor/admin roles, which stay; the scope stays the caller's own tenant.
|
|
55
|
+
readonly readRoles?: readonly string[];
|
|
52
56
|
};
|
|
53
57
|
|
|
54
58
|
export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefinition {
|
|
55
59
|
const retentionDays = options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS;
|
|
56
60
|
const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
|
|
61
|
+
const readRoles = options.readRoles ?? [];
|
|
62
|
+
if (readRoles.includes("anonymous")) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`createJobsFeature: readRoles must not contain "anonymous" — job status and failures are only readable by members of the tenant.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
57
67
|
return defineFeature("jobs", (r) => {
|
|
58
68
|
r.describe(
|
|
59
69
|
"Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops `queued` rows older than `retentionDays`.",
|
|
@@ -137,8 +147,8 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
137
147
|
list: r.queryHandler(listQuery),
|
|
138
148
|
detail: r.queryHandler(detailQuery),
|
|
139
149
|
catalog: r.queryHandler(catalogQuery),
|
|
140
|
-
failures: r.queryHandler(
|
|
141
|
-
tenantRuns: r.queryHandler(
|
|
150
|
+
failures: r.queryHandler(createTenantFailuresQuery(readRoles)),
|
|
151
|
+
tenantRuns: r.queryHandler(createTenantRunsQuery(readRoles)),
|
|
142
152
|
consumers: r.queryHandler(consumersQuery),
|
|
143
153
|
consumerDetails: r.queryHandler(consumerDetailQuery),
|
|
144
154
|
};
|
|
@@ -30,44 +30,49 @@ function parseSubject(stored: string | null): Record<string, unknown> | null {
|
|
|
30
30
|
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
33
|
+
export function createTenantFailuresQuery(readRoles: readonly string[]) {
|
|
34
|
+
return defineQueryHandler({
|
|
35
|
+
name: "failures",
|
|
36
|
+
description:
|
|
37
|
+
"Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
|
|
38
|
+
schema: z.object({
|
|
39
|
+
jobName: z.string().optional(),
|
|
40
|
+
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
41
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
42
|
+
}),
|
|
43
|
+
// Every membership rank: a failure record carries a job name and a
|
|
44
|
+
// translation key, nothing a team member of the tenant may not see.
|
|
45
|
+
access: {
|
|
46
|
+
roles: [...new Set([...access.roles("User", "Editor", ...access.admin), ...readRoles])],
|
|
47
|
+
},
|
|
48
|
+
handler: async (query, ctx) => {
|
|
49
|
+
if (!ctx.systemDb) {
|
|
50
|
+
throw new InternalError({
|
|
51
|
+
message:
|
|
52
|
+
"jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// assertTenantMatch is a self-check on the caller and returns the same
|
|
56
|
+
// unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
|
|
57
|
+
// is the filter, assertRowsTenant the second net. There is no
|
|
58
|
+
// cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
|
|
59
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
60
|
+
const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
|
|
61
|
+
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
62
|
+
if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
|
|
63
|
+
const rows = await selectMany<TenantJobFailureRow>(db, tenantJobFailuresTable, where, {
|
|
64
|
+
orderBy: { col: "failedAt", direction: "desc" },
|
|
65
|
+
limit: query.payload.limit ?? DEFAULT_LIMIT,
|
|
49
66
|
});
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
return {
|
|
64
|
-
rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
|
|
65
|
-
jobName: row.jobName,
|
|
66
|
-
subject: parseSubject(row.subject),
|
|
67
|
-
messageKey: row.messageKey,
|
|
68
|
-
failedAt: row.failedAt,
|
|
69
|
-
})),
|
|
70
|
-
nextCursor: null,
|
|
71
|
-
};
|
|
72
|
-
},
|
|
73
|
-
});
|
|
67
|
+
return {
|
|
68
|
+
rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
|
|
69
|
+
jobName: row.jobName,
|
|
70
|
+
subject: parseSubject(row.subject),
|
|
71
|
+
messageKey: row.messageKey,
|
|
72
|
+
failedAt: row.failedAt,
|
|
73
|
+
})),
|
|
74
|
+
nextCursor: null,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -32,63 +32,67 @@ function parseSubject(stored: string | null): Record<string, unknown> | null {
|
|
|
32
32
|
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
35
|
+
export function createTenantRunsQuery(readRoles: readonly string[]) {
|
|
36
|
+
return defineQueryHandler({
|
|
37
|
+
name: "tenant-runs",
|
|
38
|
+
description:
|
|
39
|
+
"Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
|
|
40
|
+
schema: z.object({
|
|
41
|
+
jobName: z.string().optional(),
|
|
42
|
+
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
43
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
44
|
+
}),
|
|
45
|
+
// Every membership rank: a run state carries a job name, a status and
|
|
46
|
+
// times, nothing a team member of the tenant may not see.
|
|
47
|
+
access: {
|
|
48
|
+
roles: [...new Set([...access.roles("User", "Editor", ...access.admin), ...readRoles])],
|
|
49
|
+
},
|
|
50
|
+
handler: async (query, ctx) => {
|
|
51
|
+
if (!ctx.systemDb) {
|
|
52
|
+
throw new InternalError({
|
|
53
|
+
message:
|
|
54
|
+
"jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// assertTenantMatch returns the same unfiltered system-mode db — the
|
|
58
|
+
// explicit tenantId below is the filter, assertRowsTenant the second net.
|
|
59
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
60
|
+
const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
|
|
61
|
+
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
62
|
+
if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
|
|
63
|
+
const limit = query.payload.limit ?? DEFAULT_LIMIT;
|
|
64
|
+
const newestFirst = { col: "updatedAt", direction: "desc" } as const;
|
|
65
|
+
const activeRows = await selectMany<TenantJobRunRow>(
|
|
66
|
+
db,
|
|
67
|
+
tenantJobRunsTable,
|
|
68
|
+
{ ...where, status: { in: ["queued", "running"] } },
|
|
69
|
+
{ orderBy: newestFirst, limit },
|
|
70
|
+
);
|
|
71
|
+
const finishedRows = await selectMany<TenantJobRunRow>(
|
|
72
|
+
db,
|
|
73
|
+
tenantJobRunsTable,
|
|
74
|
+
{ ...where, status: { in: ["completed", "failed"] } },
|
|
75
|
+
{ orderBy: newestFirst, limit },
|
|
76
|
+
);
|
|
77
|
+
const seenFinishedKeys = new Set<string>();
|
|
78
|
+
const finishedNewestPerKey = finishedRows.filter((row) => {
|
|
79
|
+
const key = JSON.stringify([row.jobName, row.subject]);
|
|
80
|
+
if (seenFinishedKeys.has(key)) return false;
|
|
81
|
+
seenFinishedKeys.add(key);
|
|
82
|
+
return true;
|
|
52
83
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const finishedRows = await selectMany<TenantJobRunRow>(
|
|
69
|
-
db,
|
|
70
|
-
tenantJobRunsTable,
|
|
71
|
-
{ ...where, status: { in: ["completed", "failed"] } },
|
|
72
|
-
{ orderBy: newestFirst, limit },
|
|
73
|
-
);
|
|
74
|
-
const seenFinishedKeys = new Set<string>();
|
|
75
|
-
const finishedNewestPerKey = finishedRows.filter((row) => {
|
|
76
|
-
const key = JSON.stringify([row.jobName, row.subject]);
|
|
77
|
-
if (seenFinishedKeys.has(key)) return false;
|
|
78
|
-
seenFinishedKeys.add(key);
|
|
79
|
-
return true;
|
|
80
|
-
});
|
|
81
|
-
const activeFirst = [...activeRows, ...finishedNewestPerKey];
|
|
82
|
-
return {
|
|
83
|
-
rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
|
|
84
|
-
jobName: row.jobName,
|
|
85
|
-
subject: parseSubject(row.subject),
|
|
86
|
-
status: row.status,
|
|
87
|
-
queuedAt: row.queuedAt,
|
|
88
|
-
startedAt: row.startedAt,
|
|
89
|
-
finishedAt: row.finishedAt,
|
|
90
|
-
})),
|
|
91
|
-
nextCursor: null,
|
|
92
|
-
};
|
|
93
|
-
},
|
|
94
|
-
});
|
|
84
|
+
const activeFirst = [...activeRows, ...finishedNewestPerKey];
|
|
85
|
+
return {
|
|
86
|
+
rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
|
|
87
|
+
jobName: row.jobName,
|
|
88
|
+
subject: parseSubject(row.subject),
|
|
89
|
+
status: row.status,
|
|
90
|
+
queuedAt: row.queuedAt,
|
|
91
|
+
startedAt: row.startedAt,
|
|
92
|
+
finishedAt: row.finishedAt,
|
|
93
|
+
})),
|
|
94
|
+
nextCursor: null,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -262,15 +262,16 @@ async function recordTenantRunQueued(
|
|
|
262
262
|
const now = Temporal.Now.instant();
|
|
263
263
|
await transaction(db, async (tx) => {
|
|
264
264
|
// A finished row under the same id is left over from before a Redis reset
|
|
265
|
-
// (ids restart at 1), not this job. Only rows
|
|
266
|
-
//
|
|
267
|
-
// and
|
|
265
|
+
// (ids restart at 1), not this job. Only rows queued before this BullMQ job
|
|
266
|
+
// was created qualify: a fast worker can finish before the hook runs (no
|
|
267
|
+
// queuedAt), and a deduplicated add of an already finished job re-announces
|
|
268
|
+
// it with a creation time older than its own row. Both rows must stay.
|
|
268
269
|
await deleteMany(tx, tenantJobRunsTable, {
|
|
269
270
|
tenantId: key.tenantId,
|
|
270
271
|
jobName,
|
|
271
272
|
bullJobId,
|
|
272
273
|
status: { in: ["completed", "failed"] },
|
|
273
|
-
queuedAt: {
|
|
274
|
+
queuedAt: { lt: Temporal.Instant.fromEpochMilliseconds(meta.jobCreatedAtMs) },
|
|
274
275
|
});
|
|
275
276
|
// DO NOTHING: a fast worker can report the start before this enqueue hook
|
|
276
277
|
// returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
|
|
@@ -35,7 +35,7 @@ runProdApp({
|
|
|
35
35
|
// decoupling — legal-pages imports no code from template-resolver,
|
|
36
36
|
// only uses the API via ctx)
|
|
37
37
|
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
38
|
-
extraContext: ({ db }) => ({
|
|
38
|
+
extraContext: ({ db, registry }) => ({
|
|
39
39
|
templateResolver: createTemplateResolverApi(db, registry),
|
|
40
40
|
}),
|
|
41
41
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenant mail transport falls back to the app transport without tenant config",
|
|
6
|
+
"detail": "tenantMailTransport returns null when the channel context carries no tenant config, instead of throwing on every send, so the channel uses the app transport as documented."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.378.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -75,9 +75,9 @@ export const MAIL_TRANSPORT_EXTENSION = "mailTransport" as const;
|
|
|
75
75
|
* config — tenant config reads (the plugin's host/port/from/...)
|
|
76
76
|
* registry — extension lookup in the factory (not plugin-internal)
|
|
77
77
|
* secrets — tenant secret reads (smtp.password)
|
|
78
|
-
* auditUserId — audit identity for secret reads. Handler path:
|
|
79
|
-
*
|
|
80
|
-
* r.job wrap sets a system identity.
|
|
78
|
+
* auditUserId — audit identity for secret reads. Handler path: unset,
|
|
79
|
+
* requireSecretsContext falls back to ctx.user.id; worker path:
|
|
80
|
+
* the r.job wrap sets a system identity.
|
|
81
81
|
*/
|
|
82
82
|
export type MailTransportContext = {
|
|
83
83
|
readonly config?: ConfigAccessor;
|
|
@@ -276,6 +276,8 @@ export type TenantMailTransportScope = {
|
|
|
276
276
|
export function tenantMailTransport(
|
|
277
277
|
scope: TenantMailTransportScope,
|
|
278
278
|
): Promise<EmailTransport | null> {
|
|
279
|
+
// Without a tenant config the provider cannot be read: use the app transport.
|
|
280
|
+
if (scope.config === undefined) return Promise.resolve(null);
|
|
279
281
|
return createTransportForTenantIfConfigured(
|
|
280
282
|
{
|
|
281
283
|
config: scope.config,
|
|
@@ -36,7 +36,7 @@ const PAT_REVOKE_TRIGGERING_FIELDS = ["passwordHash"] as const;
|
|
|
36
36
|
|
|
37
37
|
export type BindPatAutoRevokeOnPasswordChange = (
|
|
38
38
|
revoker: (userId: string) => Promise<number>,
|
|
39
|
-
) => void;
|
|
39
|
+
) => () => void;
|
|
40
40
|
|
|
41
41
|
// Reads the late-bind setter off a mounted personal-access-tokens feature's
|
|
42
42
|
// exports — run{Prod,Dev}App call this once a concrete db is available,
|
|
@@ -169,6 +169,9 @@ export function createPersonalAccessTokensFeature(
|
|
|
169
169
|
});
|
|
170
170
|
const bindAutoRevokeOnPasswordChange: BindPatAutoRevokeOnPasswordChange = (revoker) => {
|
|
171
171
|
boundAutoRevoke = revoker;
|
|
172
|
+
return () => {
|
|
173
|
+
if (boundAutoRevoke === revoker) boundAutoRevoke = undefined;
|
|
174
|
+
};
|
|
172
175
|
};
|
|
173
176
|
|
|
174
177
|
const handlers = {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "renderTablePdf validates the document for direct callers",
|
|
6
|
+
"detail": "renderTablePdf parses the document against pdfTableDocumentSchema and throws RendererError invalid_payload above the row, column and cell caps. Title, subtitle, meta entries, headers, empty text, footer text and page label now have length caps and meta at most 500 entries."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.364.0",
|
|
4
10
|
"type": "fix",
|