Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7177-studio-switcher-server-writable-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Studio's package switcher reads the server's `writable` verdict instead of guessing
from `manifest.scope` (objectui#7177, ADR-0130 Consequences row 6, server half in
objectstack#14375).

`GET /api/v1/packages` now stamps every row with `writable: boolean`, computed by
`isWritablePackage` (ADR-0070 D2) — the same predicate the server's authoring and
lifecycle gates enforce. `parsePackages` uses it when the row carries one, so the
lock badge and the gate cannot disagree.

The old `scope !== 'project'` expression stays as the fallback for servers that
predate the field, and its output is pinned byte-identical. It is wrong for exactly
one row, which is why the verdict had to move server-side: a `type: module`
sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key
at all — the schema default is applied at parse time, while the artifact load path
hands the raw manifest body to `registerApp`. The heuristic reads that as a writable
database base, while the server refuses every write to it. Nothing in the raw row
separates it from a scope-less Studio-created base, which really is writable — only
the server's `engine.manifests` does, so a client-side "missing scope means
read-only" rule would have flipped every Studio base read-only instead.

Kernel packages (`scope: system` / `cloud`) stay hidden whatever verdict they carry:
that filter is about visibility, not writability.
31 changes: 25 additions & 6 deletions packages/app-shell/src/views/studio-design/packages-io.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,11 +4,27 @@
* Package-list helpers shared by the Studio package switcher and the builder
* landing page.
*
* Writability is a DISPLAY heuristic — kernel packages (scope system/cloud)
* are hidden, `scope: 'project'` marks a read-only code package (authoring is
* refused server-side by the ADR-0070 D4 gate), and a scope-less entry is a
* database base package (writable). The gate stays the authority; this only
* sets expectations up front.
* Writability is the SERVER's verdict, not a shape we derive. Every row of
* `GET /api/v1/packages` carries a top-level `writable: boolean` computed by
* `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the
* server's authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates
* enforce, so the badge and the gate cannot disagree. Read it; do not re-derive
* it.
*
* The `scope !== 'project'` expression below is ONLY the fallback for servers
* that predate that field, and it is WRONG for one row: a `type: module`
* sub-package of a multi-package artifact (ADR-0130 D4) normally omits `scope`
* — the schema default is applied at PARSE time, while the artifact load path
* deliberately hands the RAW manifest body to `registerApp`, so the served row
* has no `scope` key at all. The heuristic reads that as a writable database
* base, yet the server refuses every write to it (it is in `engine.manifests`).
* Nothing in the raw row separates it from a scope-less Studio-created base,
* which really is writable — only the server's `engine.manifests` does, which is
* why the client cannot compute this and a "missing scope means read-only" rule
* would have flipped every Studio base read-only.
*
* Kernel packages (scope `system` / `cloud`) are hidden here whatever their
* verdict says: that filter is about visibility, not writability.
*/

import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
Expand DownExpand Up@@ -39,7 +55,10 @@ export function parsePackages(payload: unknown): PkgEntry[] {
if (scope === 'system' || scope === 'cloud') continue; // kernel — not app packages
const namespace =
typeof m.namespace === 'string' && m.namespace ? m.namespace : deriveNamespaceFromPackageId(id);
out.push({ id, name: String(m.name ?? id), writable: scope !== 'project', namespace });
// Server first, heuristic only when the key is absent (see the module doc).
// A non-boolean value is not a verdict, so it falls back too.
const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project';
out.push({ id, name: String(m.name ?? id), writable, namespace });
}
return out;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Guards WHO decides a package is writable (objectui#7177 · ADR-0130
* Consequences row 6 · objectstack#14375).
*
* The server computes `writable` with `isWritablePackage` (ADR-0070 D2) and
* stamps it on every `GET /api/v1/packages` row. `parsePackages` must READ that
* verdict, because the client cannot derive it: the signal that separates a
* booted multi-package module (read-only, `engine.manifests`) from a
* Studio-created database base (writable) lives only on the server, and BOTH of
* those rows arrive with no `scope` key.
*
* The old `scope !== 'project'` expression survives as the fallback for servers
* that predate the field — pinned here as byte-identical output, so the
* compatibility arm cannot rot unnoticed.
*/
import { describe, expect, it } from 'vitest';
import { parsePackages } from './packages-io';

function wrap(packages: Array<Record<string, unknown>>) {
return { data: { packages } };
}

/** A registry row as the runtime dispatcher serves it (`InstalledPackage` + the verdict). */
function row(manifest: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return {
manifest,
status: 'installed',
enabled: true,
installedAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
...extra,
};
}

describe('parsePackages — the server owns the writable verdict', () => {
it('honours writable:false on a scope-less row (the ADR-0130 module sub-package)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave', type: 'module' }, { writable: false })]),
);
// The row really has no scope — the verdict cannot be leaking out of one.
expect(pkg.writable).toBe(false);
expect(pkg.id).toBe('com.example.leave');
});

it('honours writable:true on a scope-less row (a Studio-created database base)', () => {
const [pkg] = parsePackages(
wrap([row({ id: 'com.example.my_base', name: 'My Base' }, { writable: true })]),
);
expect(pkg.writable).toBe(true);
});

it('lets the server win over the heuristic in BOTH directions on scope:project', () => {
const [readOnly] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: false })]),
);
expect(readOnly.writable).toBe(false);

// The heuristic would say false here; the server says true and is obeyed.
const [writable] = parsePackages(
wrap([row({ id: 'com.example.promoted', name: 'Promoted', scope: 'project' }, { writable: true })]),
);
expect(writable.writable).toBe(true);
});

it('ignores a non-boolean writable and falls back to the heuristic', () => {
// A string is not a verdict. `Boolean('false')` is `true`, so a coercing
// read would have made this row writable *and* agreed with the fallback by
// accident — the scope:project row is what tells the two apart.
const [scopeless] = parsePackages(
wrap([row({ id: 'com.example.leave', name: 'Leave' }, { writable: 'false' })]),
);
expect(scopeless.writable).toBe(true); // fallback: no scope → not 'project'

const [project] = parsePackages(
wrap([row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, { writable: 'true' })]),
);
expect(project.writable).toBe(false); // fallback: scope 'project' → read-only
});

it('hides kernel packages whatever verdict they carry (visibility is not writability)', () => {
const out = parsePackages(
wrap([
row({ id: 'objectstack.core', name: 'Core', scope: 'system' }, { writable: true }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', scope: 'cloud' }, { writable: true }),
row({ id: 'com.example.leave', name: 'Leave' }, { writable: false }),
]),
);
expect(out.map((p) => p.id)).toEqual(['com.example.leave']);
});
});

describe('parsePackages — a server with no writable field is unchanged', () => {
/**
* A realistic single-package install as an older server serves it: kernel
* packages, the `scope: 'project'` app package, a scope-less database base and
* a scope-less module. No `writable` key anywhere.
*/
const LEGACY_PAYLOAD = {
success: true,
data: {
packages: [
row({ id: 'objectstack.core', name: 'ObjectStack Core', version: '1.0.0', scope: 'system' }),
row({ id: 'com.objectstack.cloud.billing', name: 'Billing', version: '1.0.0', scope: 'cloud' }),
row({
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '1.0.0',
type: 'app',
scope: 'project',
}),
row({ id: 'com.example.my_base', name: 'My Base', version: '0.1.0' }),
row({ id: 'com.example.leave', name: 'Leave', version: '0.1.0', type: 'module', namespace: 'leave' }),
],
total: 5,
},
};

/**
* Captured by running `parsePackages` against LEGACY_PAYLOAD on the UNTOUCHED
* tree (`ad3d4029abb949cb41815b6ce38d5e0ecad1486a`), before the verdict read
* existed. Pasted, never re-derived — a re-derived expectation would agree
* with any regression this pin exists to catch.
*/
const OUTPUT_BEFORE_THIS_CHANGE = [
{ id: 'app.objectstack.hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' },
{ id: 'com.example.my_base', name: 'My Base', writable: true, namespace: 'my_base' },
{ id: 'com.example.leave', name: 'Leave', writable: true, namespace: 'leave' },
];

it('produces exactly the output it produced before the verdict read landed', () => {
expect(parsePackages(LEGACY_PAYLOAD)).toEqual(OUTPUT_BEFORE_THIS_CHANGE);
});
});
Loading