Skip to content

Commit 39adad8

Browse files
hotlongCopilot
andcommitted
fix(auth): disable requireLocalEmailVerified so trusted SSO can link unverified seed users
better-auth's account-linking gate has two independent clauses (see link-account.mjs:22). Even when the provider is trusted, the second still blocks implicit linking when the local user row has emailVerified=false (the default state for project-owner-seeded rows). Set requireLocalEmailVerified=false in the default accountLinking config so the platform SSO callback can land on pre-existing project owner rows without throwing account_not_linked. Custom deployments can override via config.account.accountLinking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3941a1f commit 39adad8

21 files changed

Lines changed: 1910 additions & 81 deletions

packages/platform-objects/src/audit/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ export { SysReportSchedule } from './sys-report-schedule.object.js';
1717
export { SysApprovalProcess } from './sys-approval-process.object.js';
1818
export { SysApprovalRequest } from './sys-approval-request.object.js';
1919
export { SysApprovalAction } from './sys-approval-action.object.js';
20+
export { SysJob } from './sys-job.object.js';
21+
export { SysJobRun } from './sys-job-run.object.js';
22+
export { SysJobQueue } from './sys-job-queue.object.js';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_job_queue — Durable Background Job / Message Queue + DLQ
7+
*
8+
* The persistent backing store for `DbQueueAdapter`. Each row is one
9+
* enqueued message — pending until a worker leases (`status='running'`),
10+
* then `completed`, `failed`, or finally `dlq` after exhausting retries.
11+
*
12+
* DLQ rows are simply `status='dlq'` rows in this same table; no separate
13+
* table needed. Listing/replay is a filter on `status`.
14+
*
15+
* Idempotency: `idempotency_key` + `queue` are deduplicated by the adapter
16+
* over a configurable window (default 24h) — the column is indexed.
17+
*
18+
* Concurrency: workers claim a row by CAS-updating `status` from `pending`
19+
* to `running` plus setting `locked_by`/`locked_until`. Reads only return
20+
* rows whose lock has not been claimed (or whose lease expired).
21+
*
22+
* Writers: `DbQueueAdapter` (publish/lease/complete/fail).
23+
* Readers: Studio DLQ view, ops dashboards, the adapter's worker loop.
24+
*
25+
* @namespace sys
26+
*/
27+
export const SysJobQueue = ObjectSchema.create({
28+
name: 'sys_job_queue',
29+
label: 'Job Queue Message',
30+
pluralLabel: 'Job Queue Messages',
31+
icon: 'inbox',
32+
isSystem: true,
33+
managedBy: 'system',
34+
description: 'Durable job/message queue including dead letters',
35+
displayNameField: 'queue',
36+
titleFormat: '{queue} #{id}',
37+
compactLayout: ['queue', 'status', 'attempts', 'scheduled_for', 'last_error'],
38+
39+
fields: {
40+
id: Field.text({ label: 'Message ID', required: true, readonly: true, group: 'System' }),
41+
42+
queue: Field.text({
43+
label: 'Queue',
44+
required: true,
45+
maxLength: 255,
46+
searchable: true,
47+
description: 'Logical queue name (snake_case)',
48+
group: 'Identity',
49+
}),
50+
51+
idempotency_key: Field.text({
52+
label: 'Idempotency Key',
53+
required: false,
54+
maxLength: 255,
55+
description: 'Deduplication key within (queue, window)',
56+
group: 'Identity',
57+
}),
58+
59+
payload_json: Field.textarea({
60+
label: 'Payload (JSON)',
61+
required: false,
62+
description: 'Serialized message body',
63+
group: 'Content',
64+
}),
65+
66+
metadata_json: Field.textarea({
67+
label: 'Metadata (JSON)',
68+
required: false,
69+
description: 'Serialized metadata bag (tenant_id, source_record, ...)',
70+
group: 'Content',
71+
}),
72+
73+
status: Field.select(
74+
['pending', 'running', 'completed', 'failed', 'dlq'],
75+
{
76+
label: 'Status',
77+
required: true,
78+
defaultValue: 'pending',
79+
description: 'Lifecycle state',
80+
group: 'State',
81+
},
82+
),
83+
84+
priority: Field.number({
85+
label: 'Priority',
86+
required: false,
87+
defaultValue: 100,
88+
description: 'Lower = higher priority',
89+
group: 'Schedule',
90+
}),
91+
92+
attempts: Field.number({ label: 'Attempts', required: false, defaultValue: 0, group: 'State' }),
93+
max_attempts: Field.number({ label: 'Max Attempts', required: false, defaultValue: 3, group: 'State' }),
94+
95+
backoff_type: Field.select(
96+
['fixed', 'exponential'],
97+
{ label: 'Backoff', required: false, defaultValue: 'exponential', group: 'Schedule' },
98+
),
99+
backoff_delay_ms: Field.number({
100+
label: 'Backoff Base (ms)',
101+
required: false,
102+
defaultValue: 1000,
103+
group: 'Schedule',
104+
}),
105+
backoff_max_delay_ms: Field.number({
106+
label: 'Backoff Cap (ms)',
107+
required: false,
108+
group: 'Schedule',
109+
}),
110+
111+
scheduled_for: Field.datetime({
112+
label: 'Scheduled For',
113+
required: false,
114+
description: 'Earliest time a worker may lease this message',
115+
group: 'Schedule',
116+
}),
117+
118+
locked_by: Field.text({
119+
label: 'Locked By',
120+
required: false,
121+
maxLength: 255,
122+
description: 'Worker id holding the lease',
123+
group: 'Lease',
124+
}),
125+
locked_until: Field.datetime({
126+
label: 'Locked Until',
127+
required: false,
128+
description: 'Lease expiry; if past, another worker may claim',
129+
group: 'Lease',
130+
}),
131+
132+
last_error: Field.textarea({ label: 'Last Error', required: false, group: 'State' }),
133+
completed_at: Field.datetime({ label: 'Completed At', required: false, group: 'State' }),
134+
135+
created_at: Field.datetime({
136+
label: 'Created At',
137+
required: true,
138+
defaultValue: 'NOW()',
139+
readonly: true,
140+
group: 'System',
141+
}),
142+
updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }),
143+
},
144+
145+
indexes: [
146+
{ fields: ['queue', 'status', 'scheduled_for'] },
147+
{ fields: ['idempotency_key', 'queue'] },
148+
{ fields: ['status'] },
149+
],
150+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_job_run — Background Job Execution History
7+
*
8+
* Each row is one execution of a registered job (`sys_job`). Stores
9+
* outcome, duration, error, and whether the run was a manual trigger or
10+
* a scheduled tick. Replaces the in-memory `executions[]` array from
11+
* `IntervalJobAdapter`.
12+
*
13+
* Writers: the active job adapter.
14+
* Readers: Studio "Job Runs" view, dashboards, alerting.
15+
*
16+
* @namespace sys
17+
*/
18+
export const SysJobRun = ObjectSchema.create({
19+
name: 'sys_job_run',
20+
label: 'Job Run',
21+
pluralLabel: 'Job Runs',
22+
icon: 'play',
23+
isSystem: true,
24+
managedBy: 'append-only',
25+
description: 'Background job execution audit trail',
26+
displayNameField: 'job_name',
27+
titleFormat: '{job_name} @ {started_at}',
28+
compactLayout: ['job_name', 'status', 'started_at', 'duration_ms', 'attempt'],
29+
30+
fields: {
31+
id: Field.text({ label: 'Run ID', required: true, readonly: true, group: 'System' }),
32+
33+
job_name: Field.text({
34+
label: 'Job',
35+
required: true,
36+
maxLength: 255,
37+
searchable: true,
38+
group: 'Identity',
39+
}),
40+
41+
status: Field.select(
42+
['running', 'success', 'failed', 'timeout'],
43+
{ label: 'Status', required: true, defaultValue: 'running', group: 'State' },
44+
),
45+
46+
started_at: Field.datetime({ label: 'Started At', required: true, group: 'State' }),
47+
completed_at: Field.datetime({ label: 'Completed At', required: false, group: 'State' }),
48+
duration_ms: Field.number({ label: 'Duration (ms)', required: false, group: 'State' }),
49+
attempt: Field.number({
50+
label: 'Attempt',
51+
required: false,
52+
defaultValue: 1,
53+
description: '1 for first run, >1 for retries/replays',
54+
group: 'State',
55+
}),
56+
57+
trigger: Field.select(
58+
['schedule', 'manual', 'replay'],
59+
{ label: 'Trigger', required: false, defaultValue: 'schedule', group: 'State' },
60+
),
61+
62+
error: Field.textarea({ label: 'Error', required: false, group: 'State' }),
63+
64+
created_at: Field.datetime({
65+
label: 'Created At',
66+
required: true,
67+
defaultValue: 'NOW()',
68+
readonly: true,
69+
group: 'System',
70+
}),
71+
},
72+
73+
indexes: [
74+
{ fields: ['job_name', 'started_at'] },
75+
{ fields: ['status', 'started_at'] },
76+
],
77+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_job — Registered Background Jobs
7+
*
8+
* Catalogue row for every job currently scheduled by an `IJobService`
9+
* implementation. Lets ops see the full list of recurring/one-off tasks
10+
* (cron, interval, once) running on this ObjectStack instance, when each
11+
* last ran, and whether it is currently active.
12+
*
13+
* Writers: the active job adapter (`DbJobAdapter` upserts on `schedule()`).
14+
* Readers: Studio "Background Jobs" view, ops dashboards.
15+
*
16+
* @namespace sys
17+
*/
18+
export const SysJob = ObjectSchema.create({
19+
name: 'sys_job',
20+
label: 'Background Job',
21+
pluralLabel: 'Background Jobs',
22+
icon: 'clock',
23+
isSystem: true,
24+
managedBy: 'system',
25+
description: 'Catalogue of registered background jobs',
26+
displayNameField: 'name',
27+
titleFormat: '{name}',
28+
compactLayout: ['name', 'schedule_type', 'active', 'last_run_at', 'last_status'],
29+
30+
fields: {
31+
id: Field.text({ label: 'Job ID', required: true, readonly: true, group: 'System' }),
32+
33+
name: Field.text({
34+
label: 'Job Name',
35+
required: true,
36+
maxLength: 255,
37+
searchable: true,
38+
description: 'Unique job identifier (snake_case)',
39+
group: 'Identity',
40+
}),
41+
42+
schedule_type: Field.select(['cron', 'interval', 'once'], {
43+
label: 'Schedule Type',
44+
required: true,
45+
group: 'Schedule',
46+
}),
47+
48+
schedule_expression: Field.text({
49+
label: 'Expression',
50+
required: false,
51+
maxLength: 200,
52+
description: 'Cron expression / interval ms / ISO datetime',
53+
group: 'Schedule',
54+
}),
55+
56+
timezone: Field.text({
57+
label: 'Timezone',
58+
required: false,
59+
maxLength: 100,
60+
group: 'Schedule',
61+
}),
62+
63+
active: Field.boolean({
64+
label: 'Active',
65+
required: true,
66+
defaultValue: true,
67+
description: 'Whether the scheduler is currently running this job',
68+
group: 'State',
69+
}),
70+
71+
last_run_at: Field.datetime({ label: 'Last Run At', required: false, group: 'State' }),
72+
last_status: Field.select(
73+
['success', 'failed', 'timeout', 'running'],
74+
{ label: 'Last Status', required: false, group: 'State' },
75+
),
76+
last_error: Field.textarea({ label: 'Last Error', required: false, group: 'State' }),
77+
run_count: Field.number({ label: 'Run Count', required: false, defaultValue: 0, group: 'State' }),
78+
failure_count: Field.number({ label: 'Failure Count', required: false, defaultValue: 0, group: 'State' }),
79+
80+
created_at: Field.datetime({
81+
label: 'Created At',
82+
required: true,
83+
defaultValue: 'NOW()',
84+
readonly: true,
85+
group: 'System',
86+
}),
87+
updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }),
88+
},
89+
90+
indexes: [
91+
{ fields: ['name'], unique: true },
92+
{ fields: ['active'] },
93+
],
94+
});

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ export class AuthManager {
175175
// include `objectstack-cloud` because it is the platform IdP.
176176
accountLinking: {
177177
enabled: true,
178+
// better-auth's account-linking gate has TWO independent clauses
179+
// (see link-account.mjs:22). Trusting the provider only satisfies
180+
// the first clause; the second — `requireLocalEmailVerified &&
181+
// !dbUser.user.emailVerified` — still blocks linking when the
182+
// pre-existing local user row has `emailVerified=false` (the
183+
// default for owner-seeded rows). Disabling the local-email gate
184+
// is safe here because the OAuth side is what we actually trust:
185+
// the incoming identity was verified by the IdP. Consumers who
186+
// need the stricter behavior can override via config.
187+
requireLocalEmailVerified: false,
178188
...((this.config as any)?.account?.accountLinking ?? {}),
179189
trustedProviders: Array.from(new Set([
180190
'objectstack-cloud',

packages/services/service-job/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
},
2020
"dependencies": {
2121
"@objectstack/core": "workspace:*",
22-
"@objectstack/spec": "workspace:*"
22+
"@objectstack/platform-objects": "workspace:*",
23+
"@objectstack/spec": "workspace:*",
24+
"croner": "^9.1.0"
2325
},
2426
"devDependencies": {
2527
"@types/node": "^25.9.0",

0 commit comments

Comments
 (0)