@cosmicdrift/kumiko-bundled-features 0.336.0 → 0.336.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/dist/auth-email-password/handlers/login.write.js +1 -2
- package/dist/custom-fields/feature.js +3 -2
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history/handlers/add-note.write.js +2 -1
- package/dist/personal-access-tokens/schema/api-token.js +5 -5
- package/dist/subscription-mollie/plugin-methods.js +3 -5
- package/dist/subscription-stripe/plugin-methods.js +23 -5
- package/dist/tenant/handlers/member-directory.query.js +16 -3
- package/dist/tenant/handlers/tenant-directory.query.js +7 -1
- package/dist/tenant-handover/feature.js +1 -1
- package/dist/user/schema/user.js +4 -3
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +3 -3
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -3
- package/package.json +9 -9
- package/src/subscription-stripe/changes.json +6 -0
- package/src/tenant/changes.json +6 -0
|
@@ -179,8 +179,7 @@ export function createLoginHandler(opts = {}) {
|
|
|
179
179
|
const statusGate = gateEnforceAccountStatus(found);
|
|
180
180
|
if (!statusGate.ok)
|
|
181
181
|
return statusGate.result;
|
|
182
|
-
// Still needed for candidate ORDER (preferred tenant first)
|
|
183
|
-
// active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
|
|
182
|
+
// Still needed for candidate ORDER (preferred tenant first) in gateResolveMembership.
|
|
184
183
|
const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
|
|
185
184
|
userId: found.id,
|
|
186
185
|
})); // @cast-boundary db-runner
|
|
@@ -79,8 +79,9 @@ function registerCustomFields(r, variant) {
|
|
|
79
79
|
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
80
80
|
// Returned EventDefs liefern .name als compile-time literal-typed string,
|
|
81
81
|
// den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
|
|
82
|
-
// `value: z.unknown()` has no field-level schema to catalog under piiFields
|
|
83
|
-
//
|
|
82
|
+
// `value: z.unknown()` has no field-level schema to catalog under piiFields
|
|
83
|
+
// (event PII covers string fields only), so "no PII" is a convention here,
|
|
84
|
+
// not an enforced guarantee.
|
|
84
85
|
const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema, {
|
|
85
86
|
piiFields: "none",
|
|
86
87
|
});
|
|
@@ -67,7 +67,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
|
|
|
67
67
|
// piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
|
|
68
68
|
// "displayName" stays plaintext (often the mailbox owner's real name for
|
|
69
69
|
// personal accounts); encrypting it would break the entity-projection read
|
|
70
|
-
// path that carries it as `personal: false
|
|
70
|
+
// path that carries it as `personal: false`.
|
|
71
71
|
r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
|
|
72
72
|
piiFields: "none",
|
|
73
73
|
});
|
|
@@ -71,8 +71,8 @@ export function createNotesHistoryFeature(opts = {}) {
|
|
|
71
71
|
// of passing an empty array.
|
|
72
72
|
if (opts.parents !== undefined && opts.parents.length === 0) {
|
|
73
73
|
throw new Error("createNotesHistoryFeature({ parents }): parents must not be an empty array — " +
|
|
74
|
-
"an empty allowlist rejects every add-note call. Omit `parents` to
|
|
75
|
-
"
|
|
74
|
+
"an empty allowlist rejects every add-note call. Omit `parents` to allow " +
|
|
75
|
+
"any registered entity the caller can read as a note parent.");
|
|
76
76
|
}
|
|
77
77
|
return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) => registerNotesHistory(r, resolveAccess(opts), opts.ownership, opts.parents));
|
|
78
78
|
}
|
|
@@ -35,7 +35,8 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
|
|
|
35
35
|
try {
|
|
36
36
|
// read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
|
|
37
37
|
// Bun.SQL poisons the whole tx after any error inside it, even one that's
|
|
38
|
-
// caught —
|
|
38
|
+
// caught — so the lookup runs in a savepoint where available, otherwise directly
|
|
39
|
+
// (pool statements are their own units).
|
|
39
40
|
authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON), async (sp) => {
|
|
40
41
|
const userRow = await fetchOne(sp, userTable, {
|
|
41
42
|
id: event.user.id,
|
|
@@ -42,27 +42,27 @@ export const apiTokenEntity = createEntity({
|
|
|
42
42
|
// store ciphertext while the resolver keeps querying for the plaintext
|
|
43
43
|
// hash — every bearer-token request would 401 once a KMS is active.
|
|
44
44
|
// Fixing that needs a blind-index-backed lookup (like email's
|
|
45
|
-
// find:"exact" companion column), a resolver.ts rewrite
|
|
46
|
-
//
|
|
45
|
+
// find:"exact" companion column), a resolver.ts rewrite, so this stays
|
|
46
|
+
// unencrypted until a blind-index lookup exists.
|
|
47
47
|
tokenHash: createTextField({
|
|
48
48
|
required: true,
|
|
49
49
|
maxLength: 64,
|
|
50
50
|
access: { write: access.privileged },
|
|
51
51
|
personal: false,
|
|
52
|
-
reason: "
|
|
52
|
+
reason: "credential_hash_used_as_exact_lookup_key",
|
|
53
53
|
}),
|
|
54
54
|
// Short displayable prefix ("your tokens" list) — not the secret itself,
|
|
55
55
|
// but still identifies which of the user's tokens this row is. Kept
|
|
56
56
|
// Klasse 1: list.query.ts returns `r.prefix` straight through with no
|
|
57
57
|
// decrypt call (unlike `name`, which goes through decryptStoredPii) — a
|
|
58
58
|
// subject annotation would leak ciphertext to the UI once a KMS is
|
|
59
|
-
// active.
|
|
59
|
+
// active.
|
|
60
60
|
prefix: createTextField({
|
|
61
61
|
required: true,
|
|
62
62
|
maxLength: 16,
|
|
63
63
|
access: { write: access.privileged },
|
|
64
64
|
personal: false,
|
|
65
|
-
reason: "
|
|
65
|
+
reason: "credential_prefix_read_without_decrypt",
|
|
66
66
|
}),
|
|
67
67
|
// JSON-encoded string[] of granted scope names — mirrors the roles-column
|
|
68
68
|
// convention (parseRoles-style text); the resolver JSON.parses it.
|
|
@@ -32,11 +32,9 @@ export function createMollieCheckoutSession(client, priceToConfig, appWebhookUrl
|
|
|
32
32
|
customerId = customer.id;
|
|
33
33
|
}
|
|
34
34
|
// mode "payment" (one-off top-up) → sequenceType.oneoff, no mandate
|
|
35
|
-
// setup.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// Webhook resolution for oneoff payments is separate follow-up
|
|
39
|
-
// work (offlot#109), not part of this contract.
|
|
35
|
+
// setup. Oneoff payments carry no subscriptionId and are not
|
|
36
|
+
// sequenceType "first", so verifyAndParseMollieWebhook drops them before
|
|
37
|
+
// any mandate/subscription handling.
|
|
40
38
|
const sequenceType = options.mode === "payment" ? SequenceType.oneoff : SequenceType.first;
|
|
41
39
|
// payments.create ist overloaded (Promise OR void mit callback);
|
|
42
40
|
// explicit cast auf Promise<Payment>-overload.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Stripe-Plugin-Methoden für die POST-tenant-resolution-Phase:
|
|
2
|
-
// createCheckoutSession, createPortalSession, cancelSubscription
|
|
2
|
+
// createCheckoutSession, createPortalSession, cancelSubscription,
|
|
3
|
+
// retrieveSubscription, retrievePrices, planSwitchSession.
|
|
3
4
|
//
|
|
4
5
|
// Werden vom Plugin-build (feature.ts) als methods auf dem
|
|
5
6
|
// SubscriptionProviderPlugin registriert. Anders als
|
|
@@ -8,7 +9,7 @@
|
|
|
8
9
|
// runtime auf (api-key aus system-secrets, audited), statt aus einem
|
|
9
10
|
// mount-time-Closure. Key-Rotation wirkt damit ohne Redeploy.
|
|
10
11
|
//
|
|
11
|
-
// **Type-Ableitung:** die options-shapes der
|
|
12
|
+
// **Type-Ableitung:** die options-shapes der methods werden
|
|
12
13
|
// **direkt vom Plugin-Contract** abgeleitet (`Parameters<NonNullable
|
|
13
14
|
// <SubscriptionProviderPlugin["...method"]>>[1]`). Wenn Foundation den
|
|
14
15
|
// Contract erweitert (z.B. neuer optionaler Field), bemerkt der
|
|
@@ -18,6 +19,11 @@ import { KNOWN_RECURRING_INTERVALS, } from "@cosmicdrift/kumiko-bundled-features
|
|
|
18
19
|
import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
19
20
|
import Stripe from "stripe";
|
|
20
21
|
import { isResourceMissingStripeError, mapStripeSubscriptionState } from "./verify-webhook.js";
|
|
22
|
+
function isUnknownCustomerStripeError(error) {
|
|
23
|
+
return (isResourceMissingStripeError(error) &&
|
|
24
|
+
error instanceof Stripe.errors.StripeInvalidRequestError &&
|
|
25
|
+
error.param === "customer");
|
|
26
|
+
}
|
|
21
27
|
export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation = true } = {}) {
|
|
22
28
|
return async (ctx, options) => {
|
|
23
29
|
// #104-Invariante: ohne billing-live darf keine Stripe-Session
|
|
@@ -28,7 +34,7 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
|
|
|
28
34
|
await runtime.assertBillingLive(ctx);
|
|
29
35
|
const stripe = await runtime.clientForCtx(ctx);
|
|
30
36
|
const mode = options.mode ?? "subscription";
|
|
31
|
-
const
|
|
37
|
+
const sessionParams = (providerCustomerId) => ({
|
|
32
38
|
mode,
|
|
33
39
|
line_items: [{ price: options.priceId, quantity: 1 }],
|
|
34
40
|
success_url: options.successUrl,
|
|
@@ -48,10 +54,22 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
|
|
|
48
54
|
// Without a customer, Stripe's default "if_required" makes a guest
|
|
49
55
|
// checkout when invoice_creation is off; the webhook then has no
|
|
50
56
|
// customer id and drops the paid payment as ignored.
|
|
51
|
-
...(!
|
|
57
|
+
...(!providerCustomerId && { customer_creation: "always" }),
|
|
52
58
|
}),
|
|
53
|
-
...(
|
|
59
|
+
...(providerCustomerId && { customer: providerCustomerId }),
|
|
54
60
|
});
|
|
61
|
+
let session;
|
|
62
|
+
try {
|
|
63
|
+
session = await stripe.checkout.sessions.create(sessionParams(options.providerCustomerId));
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (!options.providerCustomerId || !isUnknownCustomerStripeError(error))
|
|
67
|
+
throw error;
|
|
68
|
+
// Stripe account switch (test -> live) or a customer deleted in the dashboard leaves a
|
|
69
|
+
// stored customer id Stripe no longer knows; checkout creates a fresh customer instead.
|
|
70
|
+
ctx.log?.warn("subscription-stripe: stored customer unknown to Stripe, retrying without it");
|
|
71
|
+
session = await stripe.checkout.sessions.create(sessionParams(undefined));
|
|
72
|
+
}
|
|
55
73
|
if (!session.url) {
|
|
56
74
|
// Defensive: Stripe returns a hosted url for both modes today —
|
|
57
75
|
// guards against future API drift.
|
|
@@ -13,9 +13,14 @@ const KMS_POOL_CONCURRENCY = 4;
|
|
|
13
13
|
// are decrypted and filtered in memory. The cap keeps a SystemAdmin's global
|
|
14
14
|
// search from decrypting every user.
|
|
15
15
|
const SEARCH_SCAN_CAP = 1000;
|
|
16
|
+
// Display names are encrypted, so id is the only stable SQL-side key; uuid text
|
|
17
|
+
// order equals the uuid column's byte order.
|
|
18
|
+
function compareIds(a, b) {
|
|
19
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
20
|
+
}
|
|
16
21
|
async function loadMemberUserIds(db, tenantId) {
|
|
17
22
|
const memberships = await selectMany(db, tenantMembershipsTable, { tenantId });
|
|
18
|
-
return [...new Set(memberships.map((row) => String(row["userId"])))];
|
|
23
|
+
return [...new Set(memberships.map((row) => String(row["userId"])))].sort(compareIds);
|
|
19
24
|
}
|
|
20
25
|
// Label source behind every `user:user` reference column (fw#3107). The
|
|
21
26
|
// entity-convention lookup, user:query:user:list, is a SystemAdmin
|
|
@@ -52,12 +57,20 @@ export const memberDirectoryQuery = definePagedQueryHandler({
|
|
|
52
57
|
let users;
|
|
53
58
|
if (isSystemAdmin(query.user)) {
|
|
54
59
|
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as user:query:user:list did");
|
|
55
|
-
users = await selectMany(db, userTable, undefined, {
|
|
60
|
+
users = await selectMany(db, userTable, undefined, {
|
|
61
|
+
limit: scanLimit,
|
|
62
|
+
orderBy: { col: "id", direction: "asc" },
|
|
63
|
+
});
|
|
56
64
|
}
|
|
57
65
|
else {
|
|
58
66
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
59
67
|
const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, scanLimit);
|
|
60
|
-
users =
|
|
68
|
+
users =
|
|
69
|
+
userIds.length > 0
|
|
70
|
+
? await selectMany(db, userTable, { id: [...userIds] }, {
|
|
71
|
+
orderBy: { col: "id", direction: "asc" },
|
|
72
|
+
})
|
|
73
|
+
: [];
|
|
61
74
|
}
|
|
62
75
|
const resolved = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
|
|
63
76
|
const id = String(user.id);
|
|
@@ -35,7 +35,13 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
35
35
|
let tenants;
|
|
36
36
|
if (isSystemAdmin(query.user)) {
|
|
37
37
|
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did");
|
|
38
|
-
tenants = await selectMany(db, tenantTable, undefined, {
|
|
38
|
+
tenants = await selectMany(db, tenantTable, undefined, {
|
|
39
|
+
limit: scanLimit,
|
|
40
|
+
orderBy: [
|
|
41
|
+
{ col: "name", direction: "asc" },
|
|
42
|
+
{ col: "id", direction: "asc" },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
39
45
|
}
|
|
40
46
|
else {
|
|
41
47
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
@@ -13,7 +13,7 @@ export function createTenantHandoverFeature(opts = {}) {
|
|
|
13
13
|
r.describe("Try-before-signup ownership handover: claims the rows of a declared-transferable " +
|
|
14
14
|
"entity graph (root plus its parentRef-linked children) from an anonymous/public " +
|
|
15
15
|
"tenant into the caller's own tenant, legitimized by a row-bound grant the anonymous " +
|
|
16
|
-
"flow minted. Idempotent, audited via a
|
|
16
|
+
"flow minted. Idempotent, audited via a `tenant-handover:event:claimed` event on its own `tenant-handover-claim` aggregate.");
|
|
17
17
|
r.uiHints({
|
|
18
18
|
displayLabel: "Tenant Handover",
|
|
19
19
|
category: "identity",
|
package/dist/user/schema/user.js
CHANGED
|
@@ -72,12 +72,13 @@ export const userEntity = createEntity({
|
|
|
72
72
|
// login-critical read stays raw, so verifyPassword would compare a
|
|
73
73
|
// plaintext password against ciphertext and every login would fail once
|
|
74
74
|
// a KMS is active. Already an irreversible one-way hash, access-gated to
|
|
75
|
-
// privileged read/write
|
|
75
|
+
// privileged read/write. Needs a blind-index lookup before this can be
|
|
76
|
+
// subject-encrypted.
|
|
76
77
|
passwordHash: createTextField({
|
|
77
78
|
maxLength: 255,
|
|
78
79
|
access: { read: access.privileged, write: access.privileged },
|
|
79
80
|
personal: false,
|
|
80
|
-
reason: "
|
|
81
|
+
reason: "credential_hash_read_without_decrypt",
|
|
81
82
|
}),
|
|
82
83
|
// Profile — user-editable. Real name in most apps → PII. Searchable via
|
|
83
84
|
// the derived Meili index; events/projection stay ciphertext (#1610).
|
|
@@ -164,7 +165,7 @@ export const userEntity = createEntity({
|
|
|
164
165
|
reason: "technical_reference",
|
|
165
166
|
}),
|
|
166
167
|
},
|
|
167
|
-
//
|
|
168
|
+
// email is `personal: "self", find: "fuzzy"` (lookupable), so the column holds
|
|
168
169
|
// per-row ciphertext: a plain unique index on it can't catch duplicates.
|
|
169
170
|
// `unique: true` over a lookupable column makes buildEntityTable /
|
|
170
171
|
// deriveEntityTableMeta emit a second, partial unique index over the
|
|
@@ -49,10 +49,10 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
|
|
|
49
49
|
schema: z.object({ token: z.string().min(1) }),
|
|
50
50
|
access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
|
|
51
51
|
escapeHatch: {
|
|
52
|
-
reason: "
|
|
52
|
+
reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
|
|
53
53
|
"...) to compute the grace period end — the anonymous token holder has no read access " +
|
|
54
|
-
"to that tenant-config projection.
|
|
55
|
-
"SYSTEM_TENANT_ID user stream.",
|
|
54
|
+
"to that tenant-config projection. startDeletionGracePeriod also appends the user " +
|
|
55
|
+
"lifecycle event on the SYSTEM_TENANT_ID user stream.",
|
|
56
56
|
},
|
|
57
57
|
agent: { expose: false },
|
|
58
58
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
@@ -21,10 +21,10 @@ export function createRequestDeletionHandler(opts = {}) {
|
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
23
|
escapeHatch: {
|
|
24
|
-
reason: "
|
|
24
|
+
reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
|
|
25
25
|
"...) to compute the grace period end — the calling user's own identity has no read " +
|
|
26
|
-
"access to that tenant-config projection.
|
|
27
|
-
"the SYSTEM_TENANT_ID user stream.",
|
|
26
|
+
"access to that tenant-config projection. startDeletionGracePeriod also appends the " +
|
|
27
|
+
"user lifecycle event on the SYSTEM_TENANT_ID user stream.",
|
|
28
28
|
},
|
|
29
29
|
description: "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
|
|
30
30
|
agent: { risk: "high" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.336.
|
|
3
|
+
"version": "0.336.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -483,12 +483,12 @@
|
|
|
483
483
|
}
|
|
484
484
|
},
|
|
485
485
|
"dependencies": {
|
|
486
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.336.
|
|
487
|
-
"@cosmicdrift/kumiko-framework": "0.336.
|
|
488
|
-
"@cosmicdrift/kumiko-headless": "0.336.
|
|
489
|
-
"@cosmicdrift/kumiko-renderer": "0.336.
|
|
490
|
-
"@cosmicdrift/kumiko-renderer-web": "0.336.
|
|
491
|
-
"@cosmicdrift/kumiko-types": "0.336.
|
|
486
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.336.1",
|
|
487
|
+
"@cosmicdrift/kumiko-framework": "0.336.1",
|
|
488
|
+
"@cosmicdrift/kumiko-headless": "0.336.1",
|
|
489
|
+
"@cosmicdrift/kumiko-renderer": "0.336.1",
|
|
490
|
+
"@cosmicdrift/kumiko-renderer-web": "0.336.1",
|
|
491
|
+
"@cosmicdrift/kumiko-types": "0.336.1",
|
|
492
492
|
"@mollie/api-client": "^4.5.0",
|
|
493
493
|
"@node-rs/argon2": "^2.0.2",
|
|
494
494
|
"@types/mailparser": "^3.4.6",
|
|
@@ -985,8 +985,8 @@
|
|
|
985
985
|
],
|
|
986
986
|
"devDependencies": {
|
|
987
987
|
"@testing-library/user-event": "^14.6.1",
|
|
988
|
-
"@cosmicdrift/kumiko-locale-de": "0.336.
|
|
989
|
-
"@cosmicdrift/kumiko-locale-es": "0.336.
|
|
988
|
+
"@cosmicdrift/kumiko-locale-de": "0.336.1",
|
|
989
|
+
"@cosmicdrift/kumiko-locale-es": "0.336.1",
|
|
990
990
|
"jsqr": "^1.4.0"
|
|
991
991
|
}
|
|
992
992
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.336.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Checkout retries without a customer id Stripe no longer knows",
|
|
6
|
+
"detail": "Stripe checkout now retries once without the customer when the stored customer id is unknown to Stripe (for example after switching from a test to a live account), instead of failing with \"No such customer\"."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|
package/src/tenant/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.336.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Member and tenant directory lookups return a stable order under a limit",
|
|
6
|
+
"detail": "`tenant:query:member-directory` and `tenant:query:tenant-directory` now sort their rows (tenants by name then id, members by id), so a `limit` always keeps the same rows instead of whichever the database returned first."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|