@object-ui/data-objectstack 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * objectui#4252 — an app the session may not open must be distinguishable from
11
+ * an app that is not there.
12
+ *
13
+ * `GET /api/v1/meta/apps` is filtered per session server-side
14
+ * (`filterAppForUser`), so those two conditions are byte-identical in the list:
15
+ * both are simply absent. The maintainer ruling (2026-08-12) put the
16
+ * distinction on the BY-NAME route instead of flagging the list — the
17
+ * enumeration surface is not widened past what a by-name probe already implies
18
+ * — and objectstack#8013 (PR #8135) shipped it:
19
+ *
20
+ * - EXISTS + session lacks `requiredPermissions` → `403`
21
+ * `{ success: false, error: { code: 'PERMISSION_DENIED', message } }`
22
+ * - a nonexistent name, an unpublished app (ADR-0045 §3 keeps it externally
23
+ * unobservable), an app gated by an absent optional service (ADR-0057 D10 —
24
+ * nothing was denied to the CALLER) → `404 RESOURCE_NOT_FOUND`, unchanged.
25
+ *
26
+ * Every case here goes through the real `ObjectStackClient` over a stubbed
27
+ * `fetch`, so what is measured includes the client's own error stamping
28
+ * (`error.code` off `body.error.code`, `error.httpStatus` off the status) —
29
+ * the seam a hand-built rejection would have skipped straight past.
30
+ *
31
+ * The discrimination is on the ADR-0112 CODE, never the status (objectui#4408),
32
+ * and the last two cases are what hold that: they cross the two apart, so a
33
+ * reimplementation that reads `httpStatus === 403` goes red in both directions
34
+ * rather than passing on the happy path.
35
+ */
36
+
37
+ import { describe, it, expect, vi } from 'vitest';
38
+ import {
39
+ ObjectStackAdapter,
40
+ isAppPermissionDeniedError,
41
+ APP_PERMISSION_DENIED_CODE,
42
+ } from './index';
43
+
44
+ const json = (status: number, body: unknown) =>
45
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
46
+
47
+ /** The 403 body objectstack#8135 emits, transcribed from that merged diff. */
48
+ const DENIED_BODY = {
49
+ success: false,
50
+ error: {
51
+ code: 'PERMISSION_DENIED',
52
+ message: "You do not have permission to open the 'finance' app.",
53
+ },
54
+ };
55
+
56
+ /** The absence body the same route keeps answering for every other refusal. */
57
+ const ABSENT_BODY = {
58
+ error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' },
59
+ };
60
+
61
+ function makeAdapter(answer: (url: string) => Response) {
62
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => answer(String(input)));
63
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
64
+ return { adapter, fetchImpl };
65
+ }
66
+
67
+ describe('isAppPermissionDeniedError', () => {
68
+ it('matches the by-name route\'s denial code, in either ADR-0112 spelling', () => {
69
+ expect(APP_PERMISSION_DENIED_CODE).toBe('PERMISSION_DENIED');
70
+ expect(isAppPermissionDeniedError({ code: 'PERMISSION_DENIED' })).toBe(true);
71
+ // The console is versioned separately from the server it talks to; the
72
+ // pre-ADR-0112 lowercase spelling still resolves (`errorCodeIs`).
73
+ expect(isAppPermissionDeniedError({ code: 'permission_denied' })).toBe(true);
74
+ });
75
+
76
+ it('does NOT match absence, the enable-block denials, or a bare status', () => {
77
+ expect(isAppPermissionDeniedError({ code: 'RESOURCE_NOT_FOUND' })).toBe(false);
78
+ // `API_ACCESS_DENIED_CODES` (objectui#4408) is a different question: those
79
+ // are pure functions of an object's `enable` metadata and identical for
80
+ // every persona. This one is a statement about the caller.
81
+ expect(isAppPermissionDeniedError({ code: 'OBJECT_API_DISABLED' })).toBe(false);
82
+ expect(isAppPermissionDeniedError({ httpStatus: 403 })).toBe(false);
83
+ expect(isAppPermissionDeniedError(undefined)).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe('ObjectStackAdapter.probeAppAccess — over the wire', () => {
88
+ it('reports `denied` for the 403 PERMISSION_DENIED envelope', async () => {
89
+ const { adapter, fetchImpl } = makeAdapter(() => json(403, DENIED_BODY));
90
+
91
+ await expect(adapter.probeAppAccess('finance')).resolves.toBe('denied');
92
+ // The by-name address, singular — what objectstack#8013 pinned its cases
93
+ // against and what `MetadataProvider` already reads items by.
94
+ expect(String(fetchImpl.mock.calls[0][0])).toContain('/api/v1/meta/app/finance');
95
+ });
96
+
97
+ it('reports `unknown` for the 404 absence envelope — the copy must not move', async () => {
98
+ const { adapter } = makeAdapter(() => json(404, ABSENT_BODY));
99
+ await expect(adapter.probeAppAccess('no_such_app')).resolves.toBe('unknown');
100
+ });
101
+
102
+ it('reports `granted` when the route serves the app', async () => {
103
+ const { adapter } = makeAdapter(() =>
104
+ json(200, { type: 'app', name: 'finance', item: { name: 'finance', label: 'Finance' } }),
105
+ );
106
+ await expect(adapter.probeAppAccess('finance')).resolves.toBe('granted');
107
+ });
108
+
109
+ it('reports `unknown` — never `denied` — when the server cannot be reached', async () => {
110
+ const { adapter } = makeAdapter(() => {
111
+ throw new Error('network down');
112
+ });
113
+ await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown');
114
+ });
115
+
116
+ it('reports `unknown` for an empty name without asking anything', async () => {
117
+ const { adapter, fetchImpl } = makeAdapter(() => json(200, {}));
118
+ await expect(adapter.probeAppAccess('')).resolves.toBe('unknown');
119
+ expect(fetchImpl).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('never throws — a caller renders a screen off this, not a catch block', async () => {
123
+ const { adapter } = makeAdapter(() => new Response('<html>gateway</html>', { status: 502 }));
124
+ await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown');
125
+ });
126
+
127
+ // ── code, not status ─────────────────────────────────────────────────────
128
+ //
129
+ // Both directions, because a status-reading implementation passes every case
130
+ // above. 403 is not the fact; `PERMISSION_DENIED` is.
131
+
132
+ it('a 403 WITHOUT the code is `unknown` — a status alone never denies', async () => {
133
+ const { adapter } = makeAdapter(() =>
134
+ json(403, { error: { code: 'CSRF_TOKEN_INVALID', message: 'stale token' } }),
135
+ );
136
+ await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown');
137
+ });
138
+
139
+ it('the code decides even when the status is not 403', async () => {
140
+ // Hypothetical on today's server, and deliberately so: the contract this
141
+ // console consumes is the code. If the route ever answers the same denial
142
+ // under a different status, the branch must follow the code — and if this
143
+ // case is ever deleted, the reason must be that the CODE changed.
144
+ const { adapter } = makeAdapter(() => json(401, DENIED_BODY));
145
+ await expect(adapter.probeAppAccess('finance')).resolves.toBe('denied');
146
+ });
147
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * The retired cloud surface stays retired — negative pin (objectui#4152).
11
+ *
12
+ * `packages/data-objectstack/src/cloud.ts` exported a `CloudOperations` class
13
+ * and three `Cloud*` types, all re-exported from this package's `index.ts`, so
14
+ * they were published surface of `@object-ui/data-objectstack`. Every one of
15
+ * the class's four methods optional-chained into `client.cloud?.…` — a
16
+ * namespace no released `@objectstack/client` has ever exported (measured again
17
+ * at `17.0.0-rc.6` while removing it: the module exports exactly
18
+ * `ObjectStackClient`, `ScopedProjectClient`, `RealtimeAPI`, `QueryBuilder`,
19
+ * `FilterBuilder`, `createQuery`, `createFilter`, and a constructed instance's
20
+ * `.cloud` is `undefined`) — and then returned a fallback literal:
21
+ *
22
+ * | method | what it returned, always |
23
+ * |:--|:--|
24
+ * | `deploy` | `{ deploymentId: 'deploy-' + Date.now(), status: 'pending' }` |
25
+ * | `getDeploymentStatus` | `{ status: 'unknown' }` |
26
+ * | `searchMarketplace` | `[]` |
27
+ * | `installPlugin` | `{ success: false }` |
28
+ *
29
+ * The maintainer ruling of 2026-08-11 removed it. The reason worth keeping in
30
+ * front of whoever reads this file: `deploy()` did not degrade to an error, it
31
+ * **manufactured a plausible success**. A caller — especially an AI one, which
32
+ * will not get suspicious — received a well-formed `deploymentId` for an
33
+ * operation that never left the process, then polled it forever against
34
+ * `{ status: 'unknown' }`. Fabricated success is banned by that ruling; a stub
35
+ * that must exist for compile compatibility throws instead.
36
+ *
37
+ * ## Why a negative pin, and why it reads BOTH surfaces
38
+ *
39
+ * Nothing else can see this regression. The gates in this repo run from a
40
+ * declaration to its usage; a re-added export has a declaration and no usage,
41
+ * which is exactly the shape that has no alarm on it — the same gap
42
+ * objectui#4145's retirement pin was built for.
43
+ *
44
+ * The two halves are not redundant, because a class and a type do not fail the
45
+ * same way:
46
+ *
47
+ * - the **runtime** half enumerates `./index`'s bindings, which catches
48
+ * `CloudOperations` (a class survives to runtime) but is structurally blind
49
+ * to `CloudDeploymentConfig` and friends, since `export type` erases;
50
+ * - the **source-text** half reads `index.ts` for the names and for a
51
+ * re-export of `./cloud`, which is the only instrument that can see a
52
+ * returning *type*.
53
+ *
54
+ * Drop either one and half the retired surface can come back green.
55
+ *
56
+ * ## Why the source is read through `ts.sys` rather than `node:fs`
57
+ *
58
+ * Inherited from the pin this file replaces: this package's `tsconfig.json`
59
+ * compiles its whole test tree, and keeping `@types/node` out of a browser-side
60
+ * adapter's type environment is deliberate. TypeScript's own system host is
61
+ * typed by the `typescript` devDependency.
62
+ *
63
+ * ## What happened to objectui#3720's pin
64
+ *
65
+ * `cloud-environment-vocabulary.pin.test.ts` pinned the doc comment on
66
+ * `CloudDeploymentConfig.environment` — the deploy-target vocabulary and the
67
+ * `staging` trap. Its subject was deleted here, so it retired with it rather
68
+ * than being re-pointed: every fact it held was a claim ABOUT that comment, and
69
+ * its spec-side assertions (`DiscoveryEnvironmentSchema` / `EnvironmentTypeSchema`
70
+ * member lists) existed only to keep those claims honest — with the comment
71
+ * gone they would pin an upstream library's enums on behalf of no local reader,
72
+ * which is the same phantom shape objectui#4152 is closing. #3720's own
73
+ * conclusion is unaffected and now moot: it found no producer-side deploy-target
74
+ * type to converge onto, because the producer did not exist. This file records
75
+ * the same measurement one step further along.
76
+ *
77
+ * ## If a real cloud API lands
78
+ *
79
+ * Then this pin is what you delete, deliberately, in the PR that binds the new
80
+ * `@objectstack/client` surface — with methods that call it and fail loudly when
81
+ * it is absent. Re-adding the names alone, ahead of a producer, is the thing
82
+ * being prevented.
83
+ */
84
+
85
+ import { describe, it, expect } from 'vitest';
86
+ import ts from 'typescript';
87
+
88
+ import * as adapter from './index';
89
+
90
+ /** Every name the retirement removed from the package's public surface. */
91
+ const RETIRED_EXPORTS = [
92
+ 'CloudOperations',
93
+ 'CloudDeploymentConfig',
94
+ 'CloudHostingConfig',
95
+ 'CloudMarketplaceEntry',
96
+ ] as const;
97
+
98
+ /** This file, as an absolute path — `index.ts` is its sibling. */
99
+ const HERE = new URL(import.meta.url).pathname;
100
+ const INDEX_TS = HERE.replace(/[^/]+$/, 'index.ts');
101
+ const CLOUD_TS = HERE.replace(/[^/]+$/, 'cloud.ts');
102
+
103
+ function readIndexSource(): string {
104
+ const raw = ts.sys.readFile(INDEX_TS);
105
+ if (!raw) throw new Error(`cannot read ${INDEX_TS}`);
106
+ return raw;
107
+ }
108
+
109
+ const INDEX_SOURCE = readIndexSource();
110
+
111
+ describe('the probe itself works', () => {
112
+ it('read a plausible index.ts', () => {
113
+ expect(INDEX_SOURCE.length).toBeGreaterThan(1000);
114
+ expect(INDEX_SOURCE).toContain('export class ObjectStackAdapter');
115
+ });
116
+ });
117
+
118
+ describe('the cloud surface is gone from the runtime exports', () => {
119
+ it.each(RETIRED_EXPORTS)('`%s` is not a runtime binding of the package entry', (name) => {
120
+ expect(
121
+ Object.prototype.hasOwnProperty.call(adapter, name),
122
+ `\`${name}\` is exported from @object-ui/data-objectstack again. It was ` +
123
+ `retired by objectui#4152 because it optional-chained into a ` +
124
+ `\`client.cloud\` namespace @objectstack/client does not have, and ` +
125
+ `returned a fabricated success instead of failing. If a real cloud API ` +
126
+ `has landed, bind it and delete this pin deliberately — do not restore ` +
127
+ `the old fallbacks.`,
128
+ ).toBe(false);
129
+ });
130
+
131
+ it('exports no other `Cloud`-prefixed binding either', () => {
132
+ const strays = Object.keys(adapter).filter((name) => name.startsWith('Cloud'));
133
+ expect(
134
+ strays,
135
+ `unexpected \`Cloud*\` export(s) on the package entry. objectui#4152 ` +
136
+ `removed this prefix wholesale; a new one needs a producer behind it.`,
137
+ ).toEqual([]);
138
+ });
139
+ });
140
+
141
+ describe('the cloud surface is gone from the source, types included', () => {
142
+ it('index.ts re-exports nothing from `./cloud`', () => {
143
+ expect(
144
+ /from\s+'\.\/cloud'/.test(INDEX_SOURCE),
145
+ `index.ts re-exports from './cloud' again (objectui#4152 removed that ` +
146
+ `module). \`export type\` erases at runtime, so the assertions above ` +
147
+ `cannot see a returning type — this one is what catches it.`,
148
+ ).toBe(false);
149
+ });
150
+
151
+ it.each(RETIRED_EXPORTS)('index.ts does not name `%s`', (name) => {
152
+ // The retirement note left behind in index.ts mentions the family in prose
153
+ // (`Cloud*Config`), never a retired identifier, so a hit here is a real
154
+ // re-export rather than the comment.
155
+ expect(
156
+ INDEX_SOURCE.includes(name),
157
+ `index.ts mentions \`${name}\` again. If this is a comment rather than an ` +
158
+ `export, rewrite the comment — this pin deliberately cannot tell the ` +
159
+ `difference, because a type export is invisible to every other check.`,
160
+ ).toBe(false);
161
+ });
162
+
163
+ it('the cloud.ts module itself is gone', () => {
164
+ expect(
165
+ ts.sys.fileExists(CLOUD_TS),
166
+ `${CLOUD_TS} is back. The module header claimed to integrate ` +
167
+ `"@objectstack/spec v3.0.0" cloud schemas the package never consumed; ` +
168
+ `objectui#4152 deleted both the claim and the surface.`,
169
+ ).toBe(false);
170
+ });
171
+ });