Skip to content

Commit cbce5d7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22780-bulk-promoted-route-text
2 parents 51c0ef3 + 31b5a5f commit cbce5d7

12 files changed

Lines changed: 368 additions & 54 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-webhooks': patch
3+
---
4+
5+
The webhook redeliver veto now refuses a delivery whose `sys_webhook` subscription is inactive. The `active` field declares that an inactive webhook is skipped by the dispatcher, and an operator's explicit replay is the dispatcher too, so the declaration now holds on both paths.
6+
7+
Clause-②: no
8+
9+
- FROM: the redeliver door (`POST /api/v1/webhooks/redeliver`, and the `webhooks` slot's `handleRedeliver`) replayed a finished delivery whose subscription had `active` switched off. The veto refused only a subscription that was gone, or whose signing secret could not be recovered.
10+
- TO: the door answers `409 DELIVERY_NOT_ELIGIBLE` for that delivery and leaves the row as it is. The message names the webhook, says it is inactive, and names the remedy. The test is the dispatcher's own predicate, `active === true`, so a row whose `active` is null or absent is refused exactly as the dispatcher skips it. Every row the engine writes carries a boolean there: the field is required, with default `true`.
11+
- Fix, for an operator who means to replay: re-activate the webhook, then redeliver.
12+
13+
The gone-subscription and unrecoverable-secret refusals are unchanged, in text and in order. The inactive check runs after the gone check and before the secret check, so an inactive webhook whose key is also unrecoverable is refused as inactive, the first remedy to try. No code, status, field or export changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
The Setup "Create User" and "Set Password" dialogs no longer offer a "Generate Temporary Password" box the server ignores once a password is typed
6+
7+
Clause-②: no
8+
9+
The `sys_user` actions `create_user` and `set_user_password` showed two controls for one decision: a "Generate Temporary Password" box (ticked by default on Create User) and a password field labelled "leave empty to generate". With a password typed, the box did nothing, yet it was sent ticked, so its state was false whenever the field was filled.
10+
11+
The password field's label is now the only rule. `generatePassword` is no longer a dialog param on either action. It is sent as a constant `true` in each action's `bodyExtra`, and the endpoint's existing rule gives the label's behaviour:
12+
13+
- **A typed password** is the one applied, and nothing is generated.
14+
- **An empty password** generates a temporary password, shown once in the result dialog. On Set Password this is new: the box was unticked by default there, so an empty field answered `400` unless the admin ticked it.
15+
16+
The endpoints `POST /api/v1/auth/admin/create-user` and `POST /api/v1/auth/admin/set-user-password` are unchanged. A caller that sends neither a password nor `generatePassword: true` still gets `400`.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
170170
label: "Phone Number",
171171
helpText: "Sign-in phone number (E.164, e.g. +8613800000000). Required when no email is given."
172172
},
173-
generatePassword: {
174-
label: "Generate Temporary Password"
175-
},
176173
password: {
177174
label: "Password (leave empty to generate)"
178175
},
@@ -195,9 +192,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
195192
label: "Set Password",
196193
successMessage: "Password updated",
197194
params: {
198-
generatePassword: {
199-
label: "Generate Temporary Password"
200-
},
201195
newPassword: {
202196
label: "New Password (leave empty to generate)"
203197
},

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
170170
label: "Número de teléfono",
171171
helpText: "Número de teléfono para iniciar sesión (E.164, p. ej. +8613800000000). Obligatorio si no se indica un correo."
172172
},
173-
generatePassword: {
174-
label: "Generar contraseña temporal"
175-
},
176173
password: {
177174
label: "Contraseña (dejar en blanco para generarla)"
178175
},
@@ -195,9 +192,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
195192
label: "Establecer contraseña",
196193
successMessage: "Contraseña actualizada",
197194
params: {
198-
generatePassword: {
199-
label: "Generar contraseña temporal"
200-
},
201195
newPassword: {
202196
label: "Nueva contraseña (dejar en blanco para generarla)"
203197
},

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
170170
label: "電話番号",
171171
helpText: "サインイン用の電話番号(E.164 形式、例: +8613800000000)。メールアドレスを指定しない場合は必須です。"
172172
},
173-
generatePassword: {
174-
label: "一時パスワードを生成"
175-
},
176173
password: {
177174
label: "パスワード(空欄の場合は自動生成)"
178175
},
@@ -195,9 +192,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
195192
label: "パスワードを設定",
196193
successMessage: "パスワードを更新しました",
197194
params: {
198-
generatePassword: {
199-
label: "一時パスワードを生成"
200-
},
201195
newPassword: {
202196
label: "新しいパスワード(空欄の場合は自動生成)"
203197
},

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
170170
label: "手机号",
171171
helpText: "登录手机号(E.164 格式,如 +8613800000000)。未填写邮箱时必填。"
172172
},
173-
generatePassword: {
174-
label: "生成临时密码"
175-
},
176173
password: {
177174
label: "密码(留空则自动生成)"
178175
},
@@ -195,9 +192,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
195192
label: "设置密码",
196193
successMessage: "密码已更新",
197194
params: {
198-
generatePassword: {
199-
label: "生成临时密码"
200-
},
201195
newPassword: {
202196
label: "新密码(留空则自动生成)"
203197
},

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ export const SysUser = ObjectSchema.create({
199199
// the email-dependent invite flow. Hits the plugin-auth wrapper route
200200
// (NOT better-auth's stock /admin/create-user): the wrapper runs the
201201
// ADR-0068 admin gate, drives the better-auth pipeline (scrypt hash +
202-
// credential sys_account), stamps must_change_password, and — when
203-
// "Generate temporary password" is picked — returns the password ONCE
202+
// credential sys_account), stamps must_change_password, and — when the
203+
// password is left empty — returns a generated temporary password ONCE
204204
// in the response for the result dialog. It is never persisted or logged.
205205
name: 'create_user',
206206
label: 'Create User',
@@ -214,6 +214,16 @@ export const SysUser = ObjectSchema.create({
214214
requiresFeature: 'admin',
215215
successMessage: 'User created',
216216
refreshAfter: true,
217+
// One rule for the admin: the password field's own label, "leave empty
218+
// to generate". `generatePassword` is therefore a constant the dialog
219+
// never offers, not a param: the endpoint lets a non-empty explicit
220+
// password win over it and generates only when the password is empty,
221+
// which is exactly what the label says. A ticked box beside a typed
222+
// password was a control the server ignored. It is not a hidden param
223+
// either — `visible: false` drops the value from the submission too, and
224+
// the endpoint answers an empty password without `generatePassword: true`
225+
// with a 400 (its contract for other callers, unchanged).
226+
bodyExtra: { generatePassword: true },
217227
params: [
218228
// The endpoint requires email OR phone (phone-only users get a
219229
// placeholder address; requires auth.plugins.phoneNumber).
@@ -231,13 +241,6 @@ export const SysUser = ObjectSchema.create({
231241
requiresFeature: 'phoneNumber',
232242
},
233243
{ field: 'name', required: false },
234-
{
235-
name: 'generatePassword',
236-
label: 'Generate Temporary Password',
237-
type: 'boolean',
238-
required: false,
239-
defaultValue: true,
240-
},
241244
{ name: 'password', label: 'Password (leave empty to generate)', type: 'text', required: false },
242245
{
243246
name: 'mustChangePassword',
@@ -281,14 +284,11 @@ export const SysUser = ObjectSchema.create({
281284
recordIdParam: 'userId',
282285
successMessage: 'Password updated',
283286
refreshAfter: false,
287+
// Same one rule as `create_user` above: the new-password field's label
288+
// ("leave empty to generate") is the whole instruction, and
289+
// `generatePassword` rides as a constant so an empty field generates.
290+
bodyExtra: { generatePassword: true },
284291
params: [
285-
{
286-
name: 'generatePassword',
287-
label: 'Generate Temporary Password',
288-
type: 'boolean',
289-
required: false,
290-
defaultValue: false,
291-
},
292292
{ name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'text', required: false },
293293
{
294294
name: 'mustChangePassword',

‎packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, vi } from 'vitest';
44
import { assertEngineUpdateDispatch } from '@objectstack/objectql';
5+
import { SysUser } from '@objectstack/platform-objects/identity';
56
import {
67
runAdminCreateUser,
78
runAdminSetUserPassword,
@@ -146,9 +147,13 @@ describe('runAdminCreateUser', () => {
146147
});
147148

148149
it('rejects when neither password nor generatePassword is provided', async () => {
150+
// CONTROL for the Setup dialog's one password rule: a caller that is not
151+
// the dialog and sends neither still gets the 400 — the endpoint's
152+
// contract did not move when the dialog started sending the constant.
149153
const m = makeDeps();
150154
const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'a@b.co' }), ACTOR);
151155
expect(res.status).toBe(400);
156+
expect(res.body.error?.code).toBe('INVALID_REQUEST');
152157
expect(m.createUser).not.toHaveBeenCalled();
153158
});
154159

@@ -703,6 +708,130 @@ describe('runAdminSetUserPassword', () => {
703708
expect(meta.event).toBe('user.admin_password_set');
704709
expect(meta.passwordGenerated).toBe(true);
705710
});
711+
712+
it('prefers an explicit newPassword over generatePassword (the dialog sends both)', async () => {
713+
const m = makeDeps();
714+
const res = await runAdminSetUserPassword(
715+
m.deps,
716+
makeRequest({ userId: 'user-9', newPassword: 'Str0ng!Pass', generatePassword: true }),
717+
ACTOR,
718+
);
719+
expect(res.status).toBe(200);
720+
expect(m.authCtx.internalAdapter.updatePassword).toHaveBeenCalledWith('user-9', 'hashed(Str0ng!Pass)');
721+
expect((res.body.data as any).temporaryPassword).toBeUndefined();
722+
});
723+
724+
it('generates when generatePassword is true and newPassword is empty', async () => {
725+
const m = makeDeps();
726+
const res = await runAdminSetUserPassword(
727+
m.deps,
728+
makeRequest({ userId: 'user-9', newPassword: '', generatePassword: true }),
729+
ACTOR,
730+
);
731+
expect(res.status).toBe(200);
732+
const temp = (res.body.data as any).temporaryPassword as string;
733+
expect(typeof temp).toBe('string');
734+
expect(m.authCtx.internalAdapter.updatePassword).toHaveBeenCalledWith('user-9', `hashed(${temp})`);
735+
});
736+
737+
it('rejects when neither newPassword nor generatePassword is provided', async () => {
738+
// CONTROL, as for create-user: a caller that is not the dialog and sends
739+
// neither still gets the 400, and nothing is written.
740+
const m = makeDeps();
741+
const res = await runAdminSetUserPassword(m.deps, makeRequest({ userId: 'user-9' }), ACTOR);
742+
expect(res.status).toBe(400);
743+
expect(res.body.error?.code).toBe('INVALID_REQUEST');
744+
expect(m.authCtx.internalAdapter.updatePassword).not.toHaveBeenCalled();
745+
expect(m.authCtx.internalAdapter.createAccount).not.toHaveBeenCalled();
746+
});
747+
});
748+
749+
// ── The Setup dialogs: the password field is the one rule ──────────────────
750+
//
751+
// `sys_user`'s `create_user` and `set_user_password` actions are this
752+
// endpoint's first-party callers. Their dialog offers one rule — the password
753+
// field's own label, "leave empty to generate" — so `generatePassword` is not a
754+
// control the admin sees: it rides in `bodyExtra` as a constant, and the
755+
// explicit-wins rule above turns that into the label's behaviour. A ticked box
756+
// beside a typed password was a control the server ignored.
757+
//
758+
// These pins read the DECLARED actions and compose the request body the way
759+
// the console does for a `type: 'api'` action: the values the dialog collected,
760+
// the `recordIdParam`, then `bodyExtra` merged last (the spec's documented
761+
// semantics for that key). The dialog submits only the params it shows, so a
762+
// param left out of `params` is never on the wire — `bodyExtra` is the one
763+
// place a constant the admin does not see can come from.
764+
describe('Setup dialogs: one password rule (sys_user create_user / set_user_password)', () => {
765+
function declaredAction(name: string) {
766+
const action = (SysUser.actions ?? []).find((a) => a.name === name);
767+
if (!action) throw new Error(`sys_user declares no action named "${name}"`);
768+
return action;
769+
}
770+
771+
function dialogBody(name: string, collected: Record<string, unknown>, recordId?: string) {
772+
const action = declaredAction(name);
773+
const body: Record<string, unknown> = { ...collected };
774+
if (action.recordIdParam && recordId !== undefined) body[action.recordIdParam] = recordId;
775+
return { ...body, ...(action.bodyExtra ?? {}) };
776+
}
777+
778+
const DIALOGS = [
779+
{
780+
action: 'create_user',
781+
explicitKey: 'password',
782+
base: { email: 'a@b.co', mustChangePassword: true },
783+
recordId: undefined,
784+
run: (m: ReturnType<typeof makeDeps>, body: Record<string, unknown>) =>
785+
runAdminCreateUser(m.deps, makeRequest(body), ACTOR),
786+
appliedPassword: (m: ReturnType<typeof makeDeps>) =>
787+
callsOf(m.createUser).map((c) => (c[0] as unknown as { body: { password: string } }).body.password),
788+
},
789+
{
790+
action: 'set_user_password',
791+
explicitKey: 'newPassword',
792+
base: { mustChangePassword: true },
793+
recordId: 'user-9',
794+
run: (m: ReturnType<typeof makeDeps>, body: Record<string, unknown>) =>
795+
runAdminSetUserPassword(m.deps, makeRequest(body), ACTOR),
796+
appliedPassword: (m: ReturnType<typeof makeDeps>) =>
797+
callsOf(m.authCtx.password.hash).map((c) => c[0]),
798+
},
799+
] as const;
800+
801+
for (const d of DIALOGS) {
802+
it(`${d.action}: offers no generatePassword control and sends it as a constant`, () => {
803+
const action = declaredAction(d.action);
804+
const offered = (action.params ?? []).map((p) => p.name ?? p.field);
805+
expect(offered).toContain(d.explicitKey);
806+
expect(offered).not.toContain('generatePassword');
807+
expect(action.bodyExtra?.generatePassword).toBe(true);
808+
});
809+
810+
it(`${d.action}: a typed password is the one applied, and nothing is generated`, async () => {
811+
const m = makeDeps();
812+
const body = dialogBody(d.action, { ...d.base, [d.explicitKey]: 'Explicit1!' }, d.recordId);
813+
const res = await d.run(m, body);
814+
expect(res.status).toBe(200);
815+
expect(d.appliedPassword(m)).toEqual(['Explicit1!']);
816+
expect((res.body.data as any).temporaryPassword).toBeUndefined();
817+
});
818+
819+
for (const [shape, collected] of [
820+
['left untouched (absent)', {}],
821+
['typed then cleared (empty string)', { [d.explicitKey]: '' }],
822+
] as const) {
823+
it(`${d.action}: a password ${shape} generates a temporary one, returned once`, async () => {
824+
const m = makeDeps();
825+
const body = dialogBody(d.action, { ...d.base, ...collected }, d.recordId);
826+
const res = await d.run(m, body);
827+
expect(res.status).toBe(200);
828+
const temp = (res.body.data as any).temporaryPassword as string;
829+
expect(typeof temp).toBe('string');
830+
expect(d.appliedPassword(m)).toEqual([temp]);
831+
expectNoPasswordLeak(m, temp);
832+
});
833+
}
834+
}
706835
});
707836

708837
// ── The ledger row records the admin's decisions, never a field value ──────

0 commit comments

Comments
 (0)