Skip to content

Commit b7335d8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21464-s-final
2 parents 846a055 + 33f9791 commit b7335d8

50 files changed

Lines changed: 2229 additions & 141 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`POST /api/v1/security/explain` with `{ object, operation: 'read', recordId }` now credits read depth for a row that only the caller's read depth admits.
6+
7+
Clause-②: no
8+
9+
- **Before.** The object's OWD is private. The caller's read depth is wider than `own` (`own_and_reports`, `unit`, `unit_and_below` or `org`). The caller reads a row they do not own and hold no share on. Explain answered `decidedBy: 'sharing'` for that row, with the sharing layer `admitted` and the detail "0 share(s) attached; access is granted for this record." No share granted anything; the read depth did. `depth` is a member of the published `decidedBy` enum, and no answer ever produced it.
10+
- **Now.** That row answers `decidedBy: 'depth'`. The `depth` layer carries a `record` block: `admitted`, naming the depth and, below `org`, the owner it reached. The sharing layer is `not_evaluated`, and its detail says that no share grants the row and that read depth already admits it.
11+
- **The sharing detail names a grant only when one exists**, meaning a share that names the caller. A filter that admits a row with no such share now says which part of the sharing service let the row through.
12+
- **A row a share admits keeps `decidedBy: 'sharing'`** and its "N share(s) attached; access is granted for this record." detail, also when the read depth admits it too. Of the two layers that admit it, sharing comes last in the pipeline, and the `decidedBy` contract names the last layer to admit.
13+
- **No access decision changes.** `visible` is the same for every record; only the layer the report credits moves. No key is added. `depth` and `not_evaluated` are existing values.
14+
- **The `depth` layer's `record` block is new on every record-grained request.** On a write it is `not_evaluated`, because the write depth is judged inside the sharing service's per-record write gate together with ownership and shares, and this report does not separate them.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
A user who can edit a record may delete another user's attachment on it, as the attachment gate declares (#21729).
7+
8+
Clause-②: yes (widening)
9+
10+
- **What was refused.** The attachment gate's delete rule is "the uploader OR a user who can edit the parent record". For every member holding `org_member`, the platform's row-level delete floor in `member_default` (`owner_only_deletes`: only the rows you created) answered first, so a parent editor's delete of someone else's attachment was refused with `PERMISSION_DENIED` before the gate ran.
11+
- **`@objectstack/service-storage`** contributes a delete-only alternate match for `sys_attachment` (`sys_attachment_parent_editor_delete`, every row) when it installs the attachment gate, and only then. The gate decides: a parent editor's delete answers 200, and a caller who can read the attachment but neither uploaded it nor can edit the parent is refused with `ATTACHMENT_DELETE_DENIED`. A caller who cannot read the parent cannot see the attachment, and is still refused with `PERMISSION_DENIED` before the gate runs, so the parent is not named to them.
12+
- **Without `@objectstack/service-storage`** nothing is contributed. A deployment that registers `sys_attachment` without the storage service keeps the floor, and only a row's creator may delete it.
13+
- **The edit limb is unchanged.** Editing another user's attachment row is still refused by the floor for a member it binds, a parent editor included.
14+
- **`@objectstack/plugin-security`** gains the seam: `contributeOwnershipFloorAlternates(plugin, alternates)` on the registered `security` service, an extension of `ISecurityService` that callers feature-detect. Each alternate names one object (never `'*'`), one floor limb (`update` or `delete`; `all` is refused) and a `using` predicate. It lands beside each enabled floor policy of that limb, in that policy's own `positions` domain, so it reaches only the principals the floor binds. A plugin's second call replaces its first, and an empty list withdraws it. A contribution that breaks these rules throws.
15+
16+
Nothing that was admitted before is refused now. No principal outside the floor's domain, and no other object or operation, changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
A file field's declared `accept` / `maxSize` refusal now answers `400 ERR_FILE_CONSTRAINT` with a sentence naming the field and the constraint, instead of `500 INTERNAL_ERROR` with the sentence withheld.
6+
7+
Clause-②: no
8+
9+
- **`FileConstraintError` declares `status = 400`**, as `FileFieldBulkWriteError` in the same module already did. The data API's declared-status passthrough now answers the refusal on create and on update, e.g. `400 {"error":"File exceeds the maximum size declared for 'doc' (5005 bytes > 10 bytes)","code":"ERR_FILE_CONSTRAINT","object":"…"}`. Before, the error declared a registered `code` but no status, so it fell through to the sanitised `500 INTERNAL_ERROR`, and the field and the reason reached only the server log.
10+
- **It carries `field` and `constraint` (`'accept' | 'maxSize'`) as members**, for in-process callers. The constructor is now `new FileConstraintError(field, constraint, message)`. The new `FileConstraint` type is exported beside it. On the HTTP wire the message names both, and its wording is unchanged.
11+
- The accept set is unchanged: the same files are refused, and a refused write still persists no row and claims no file. `ERR_FILE_CONSTRAINT` was already in the error-code ledger.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
A TOTP enrollment names the deployment, not the auth library. `/two-factor/enable` and `/two-factor/get-totp-uri` answered an otpauth URI whose issuer and label prefix were `Better Auth`, so every authenticator app listed the account under that name. They now carry the deployment's app name: `OS_APP_NAME`, else the configured `appName`, else `ObjectStack`. An explicitly set `branding.workspace_name` setting still outranks it.
7+
8+
Clause-②: no
9+
10+
- **Existing enrollments keep working.** The issuer is a display label. The stored enrollment holds only the encrypted secret, the backup codes and the confirmation flag, and the codes depend only on the secret, digits and period. An authenticator app enrolled under `Better Auth` keeps producing codes that verify. It keeps its old label until the user re-enrolls.
11+
- **`@objectstack/plugin-auth`.** `AuthManager` passes its app name to better-auth as `appName`. In better-auth 1.7.3 that key names only these two otpauth URIs. No cookie name or stored value derives from it.
12+
- **`@objectstack/cli`.** `objectstack serve` now passes the deployment app name to `AuthPlugin`. It is resolved by the same chain the email service's template context uses: `OS_APP_NAME` > `config.email.appName` > `config.email.defaultTemplateContext.appName` > `config.appName` > `ObjectStack`. Before, `serve` built `AuthPlugin` with no app name, so auth answered `ObjectStack` whatever `OS_APP_NAME` said. Auth emails were affected too: under `serve` they now name the deployment the way every other email already did.
13+
- The issuer is read when the auth instance is built. A `branding.workspace_name` change made after that reaches new enrollments at the next restart or auth-settings change, while auth emails pick it up on their next send.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ Self-referential hierarchy (e.g., categories, org chart).
362362

363363
## Media Types
364364

365-
Media fields store a reference to an uploaded file. They take the universal field properties (`multiple` is honored for `file` and `image`); there is no per-field file-attachment config object in the field schema — file constraints and storage are configured at the storage-provider layer, not on the field.
365+
Media fields store a reference to an uploaded file. They take the universal field properties (`multiple` is honored for `file` and `image`). Two more field properties apply to the media family (`file`, `image`, `avatar`, `video` and `audio`) and constrain the referenced file: `accept`, the permitted upload types as MIME types or extensions (e.g. `["image/*", ".pdf"]`), offered to the file picker and enforced on write; and `maxSize`, the maximum permitted file size in bytes, enforced on write against the stored file size, not just checked in the browser. A create or update whose referenced file violates either is refused with `400 ERR_FILE_CONSTRAINT`, and the error message names the field and the constraint. Storage itself is configured at the storage-provider layer, not on the field.
366366

367367
### `image`
368368
Image file with optional validation.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Auth settings precedence:
123123
|:---|:---|:---|:---|
124124
| `OS_PRODUCT_NAME` | string | `ObjectStack` | Product name shown in the UI chrome and emails. |
125125
| `OS_PRODUCT_SHORT_NAME` | string | `OS` | Short name used in compact UI surfaces (favicons, mobile titles). |
126-
| `OS_APP_NAME` | string | — | Application name surfaced to auth providers (used as the OAuth app display name). |
126+
| `OS_APP_NAME` | string | — | The deployment's product name under `objectstack serve`: `{{appName}}` in every email template (auth emails included), the issuer an authenticator app lists a TOTP enrollment under, and the MCP OAuth resource name. Wins over `config.email.appName`, `config.email.defaultTemplateContext.appName` and `config.appName`; with none of them set the name is `ObjectStack`. An explicitly set `branding.workspace_name` setting outranks it in auth. |
127127

128128
---
129129

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,19 @@ editable by design). A multi-delete requires *every* matched row to pass.
7070

7171
| Code | Status | When |
7272
| --- | --- | --- |
73-
| `ATTACHMENT_DELETE_DENIED` | 403 | The caller is neither the uploader nor able to edit the parent record |
73+
| `ATTACHMENT_DELETE_DENIED` | 403 | The caller can read the attachment, but is neither the uploader nor able to edit the parent record |
74+
| `PERMISSION_DENIED` | 403 | The caller cannot read the parent record, so the attachment is not visible to them; the delete is refused before the attachment gate runs, and the parent is not named |
75+
76+
The platform baseline also ships a parent-blind row-level delete floor
77+
(`owner_only_deletes`: you may delete only the rows you created, for members
78+
holding `org_member`). It would refuse a parent editor before this gate is
79+
asked, so the storage service contributes a delete-only alternate match beside
80+
the floor when it installs the gate, and the gate decides. The alternate match
81+
exists only where the gate does: a deployment that registers `sys_attachment`
82+
without `@objectstack/service-storage` keeps the floor, and there only the row's
83+
creator may delete it. The floor's edit limb is not relieved: a member the
84+
floor binds still cannot edit another user's attachment row
85+
(`PERMISSION_DENIED`), even as a parent editor.
7486

7587
<Callout type="info">
7688
The platform baseline permission set (`member_default`, the `everyone`
@@ -151,7 +163,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
151163
| Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) |
152164
| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
153165
| List / read | inherits parent read visibility | *(filtered out)* |
154-
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` / `PERMISSION_DENIED` (403) |
166+
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |
155167
| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
156168

157169
## See also
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `objectstack serve` hands the deployment's product name to AuthPlugin, from
4+
// the SAME resolver the email capability's template context uses.
5+
//
6+
// The name reaches two auth surfaces through `AuthManager.getAppName()`: the
7+
// `{{appName}}` of every auth email, and the issuer and label prefix of the
8+
// otpauth URI a TOTP enrollment hands to an authenticator app. AuthPlugin used
9+
// to be constructed with no `appName`, so under `serve` `getAppName()` answered
10+
// 'ObjectStack' whatever `OS_APP_NAME` said. Auth emails hid it: their own
11+
// `appName` outranks the email service's template context, which DID carry
12+
// `OS_APP_NAME`, so an operator who set it saw the env var honoured in every
13+
// other mail and the framework's name in the auth ones.
14+
//
15+
// Two halves, both needed. The resolver half proves the chain is the email
16+
// chain, rung for rung, so the two consumers cannot disagree about the name.
17+
// The construction half scans `serve.ts` (comments masked, as its siblings
18+
// `serve-email-config-parity.contract.test.ts` and
19+
// `serve-verify-security-parity.contract.test.ts` do), because nothing else
20+
// sees the AuthPlugin literal: the plugin is imported through a variable
21+
// specifier, so the type checker cannot, and only a booted CLI could.
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { readFileSync } from 'node:fs';
25+
import path from 'node:path';
26+
import { fileURLToPath } from 'node:url';
27+
import { resolveDeploymentAppName, resolveEmailCapabilityArg } from './serve.js';
28+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
29+
30+
const SERVE_SOURCE = maskComments(
31+
readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'serve.ts'), 'utf8'),
32+
);
33+
34+
/** The text between the parenthesis or brace opening at `open` and its match. */
35+
function balanced(source: string, open: number): string {
36+
const pairs: Record<string, string> = { '(': ')', '{': '}' };
37+
const close = pairs[source[open]];
38+
if (!close) throw new Error(`no bracket at offset ${open}`);
39+
let depth = 0;
40+
for (let i = open; i < source.length; i++) {
41+
if (source[i] === source[open]) depth++;
42+
else if (source[i] === close && --depth === 0) return source.slice(open + 1, i);
43+
}
44+
throw new Error(`unbalanced bracket opened at offset ${open}`);
45+
}
46+
47+
/** Every `<callee>(` call's argument text, declarations excluded. */
48+
function callArguments(source: string, callee: string): string[] {
49+
const out: string[] = [];
50+
const needle = `${callee}(`;
51+
for (let i = source.indexOf(needle); i !== -1; i = source.indexOf(needle, i + 1)) {
52+
if (/function\s+$/.test(source.slice(Math.max(0, i - 20), i))) continue;
53+
out.push(balanced(source, i + callee.length));
54+
}
55+
return out;
56+
}
57+
58+
/** Argument text compared as code: whitespace and a trailing comma dropped. */
59+
const normalise = (args: string) => args.replace(/\s+/g, '').replace(/,$/, '');
60+
61+
const emailAppName = (cfgEmail: Record<string, any>, env: NodeJS.ProcessEnv, configAppName?: string) =>
62+
(resolveEmailCapabilityArg(cfgEmail, env, configAppName).options
63+
.defaultTemplateContext as Record<string, unknown>).appName;
64+
65+
describe('resolveDeploymentAppName is the email chain, rung for rung', () => {
66+
const RUNGS: Array<[string, Record<string, any>, NodeJS.ProcessEnv, string | undefined, string]> = [
67+
['OS_APP_NAME', { appName: 'Key', defaultTemplateContext: { appName: 'Context' } }, { OS_APP_NAME: 'Env' }, 'Top', 'Env'],
68+
['config.email.appName', { appName: 'Key', defaultTemplateContext: { appName: 'Context' } }, {}, 'Top', 'Key'],
69+
['defaultTemplateContext.appName', { defaultTemplateContext: { appName: 'Context' } }, {}, 'Top', 'Context'],
70+
['top-level config.appName', {}, {}, 'Top', 'Top'],
71+
["'ObjectStack'", {}, {}, undefined, 'ObjectStack'],
72+
];
73+
74+
for (const [rung, cfgEmail, env, top, expected] of RUNGS) {
75+
it(`${rung} decides both consumers`, () => {
76+
expect(resolveDeploymentAppName(cfgEmail, env, top)).toBe(expected);
77+
expect(emailAppName(cfgEmail, env, top)).toBe(expected);
78+
});
79+
}
80+
});
81+
82+
describe('serve constructs AuthPlugin with the deployment app name', () => {
83+
it('passes appName from resolveDeploymentAppName, with the arguments the email capability gets', () => {
84+
const constructions = callArguments(SERVE_SOURCE, 'new AuthPlugin');
85+
// Absence must be loud: a scan that finds no construction proves nothing.
86+
expect(constructions.length, 'serve.ts must construct AuthPlugin exactly once').toBe(1);
87+
88+
const literal = constructions[0];
89+
const passed = callArguments(literal, 'resolveDeploymentAppName');
90+
expect(passed.length, 'AuthPlugin must receive appName from resolveDeploymentAppName').toBe(1);
91+
expect(literal).toMatch(/\bappName:\s*resolveDeploymentAppName\(/);
92+
93+
const emailCalls = callArguments(SERVE_SOURCE, 'resolveEmailCapabilityArg');
94+
expect(emailCalls.length, 'serve.ts must resolve the email capability exactly once').toBe(1);
95+
expect(normalise(passed[0])).toBe(normalise(emailCalls[0]));
96+
});
97+
});

‎packages/cli/src/commands/serve.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4083,6 +4083,11 @@ export default class Serve extends Command {
40834083
await kernel.use(new AuthPlugin({
40844084
secret,
40854085
baseUrl,
4086+
// The deployment's product name, from the resolver the email
4087+
// capability's template context uses: the auth emails'
4088+
// `{{appName}}` and the TOTP issuer. Unpassed, auth answered
4089+
// 'ObjectStack' whatever `OS_APP_NAME` said.
4090+
appName: resolveDeploymentAppName((config as any).email ?? {}, process.env, (config as any).appName),
40864091
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
40874092
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
40884093
// Enable the admin plugin by default so the Setup app's
@@ -6405,6 +6410,27 @@ function envBooleanFlag(raw: string | undefined): boolean | undefined {
64056410
return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
64066411
}
64076412

6413+
/**
6414+
* The deployment's product name: `OS_APP_NAME` > `config.email.appName` >
6415+
* `config.email.defaultTemplateContext.appName` > top-level `config.appName` >
6416+
* `'ObjectStack'` (the chain {@link resolveEmailCapabilityArg} documents).
6417+
*
6418+
* ONE resolver for every boot-time consumer of the name, so they cannot
6419+
* disagree: the email capability's template context, and the `appName`
6420+
* AuthPlugin receives (the auth emails' `{{appName}}` and the TOTP issuer an
6421+
* authenticator app lists the account under). AuthPlugin used to be built
6422+
* without it, so `OS_APP_NAME` never reached auth: its emails, whose own
6423+
* `appName` outranks the template context, said 'ObjectStack'.
6424+
*/
6425+
export function resolveDeploymentAppName(
6426+
cfgEmail: Record<string, any> = {},
6427+
env: NodeJS.ProcessEnv = process.env,
6428+
configAppName?: string,
6429+
): string {
6430+
return env.OS_APP_NAME || cfgEmail.appName || cfgEmail.defaultTemplateContext?.appName
6431+
|| configAppName || 'ObjectStack';
6432+
}
6433+
64086434
/**
64096435
* Resolve what `EmailServicePlugin` is constructed with, from `config.email`
64106436
* plus `OS_EMAIL_*` env (env wins, so an operator can override per environment).
@@ -6520,8 +6546,7 @@ export function resolveEmailCapabilityArg(
65206546
const cfgTemplateContext = cfgEmail.defaultTemplateContext || {};
65216547
const defaultTemplateContext = {
65226548
...cfgTemplateContext,
6523-
appName: env.OS_APP_NAME || cfgEmail.appName || cfgTemplateContext.appName
6524-
|| configAppName || 'ObjectStack',
6549+
appName: resolveDeploymentAppName(cfgEmail, env, configAppName),
65256550
};
65266551
// Provide a sensible fallback `from` so templates can render even before
65276552
// operators configure SMTP/SaaS. The log transport simply prints to stdout;

0 commit comments

Comments
 (0)