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
14 changes: 14 additions & 0 deletions .changeset/30323-sample-data-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/cloud-connection': patch
---

feat(cloud-connection): `GET /api/v1/marketplace/install-local/:manifestId/sample-data` answers how many of an installed app's sample records a purge would delete now

Clause-②: yes (widening)
Metadata-protocol: none

- **New read-only route.** `GET /api/v1/marketplace/install-local/:manifestId/sample-data` answers `{ success: true, data: { manifestId, count, unidentified } }`. `count` is the number of rows `POST …/purge-sample-data` would delete if it ran now, found by the purge's own matcher with nothing deleted. A record a user added is not counted, and neither is a sample record whose key field (its dataset's `externalId`) was edited, because the purge deletes neither. A sample record edited only in other fields is still a sample row: the purge deletes it and the count includes it. `unidentified` counts the sample records the purge would report in `errors` and leave in place (no usable key value, or a key two rows carry). Before this, the count was known only from the install answer's `data.seeded`, so after a reload no API could say how much sample data was left; the installed-apps listing's `withSampleData` says only whether any is left.
- **Same rules as the purge.** It requires `manage_metadata`, answers `404` for a package that is not installed and `422 OS_PROTOCOL_INCOMPATIBLE` for an entry this runtime refused to load, and counts in the purge's scope (the caller's active organization under an organization wall, every row without one). A walled session with no active organization gets `count: 0`, the same answer as the listing's `withSampleData: false` for that session. A package with no seed datasets gets `count: 0`. With no data engine or metadata service it answers `500 DRIVER_UNAVAILABLE`, never `0`.
- **It cannot delete.** It is a `GET` and accepts no query parameter: any parameter (`dryRun`, `confirm`, …) is refused with `400 VALIDATION_ERROR` before any row is read.
- **Unchanged.** What the purge deletes, its permission and its answer; the listing's `withSampleData`.
- `@objectstack/cloud-connection` now depends on `@objectstack/rest`, for the shared closed-query-set refusal message.
1 change: 1 addition & 0 deletions packages/cloud-connection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/observability": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/runtime": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* eight doors refuse on it; `/status` reads it only to decide what it
* serves (#24148) and refuses no one.
* - `MarketplaceInstallLocalPlugin` reads it in ONE place
* (`resolveInstallPrincipal`), and all five doors answer a held session
* (`resolveInstallPrincipal`), and all six doors answer a held session
* through the same refusal (`refuseHeldSession`).
*
* Each consults `evaluateAuthGate` (`@objectstack/core`) once, with no request
Expand Down Expand Up @@ -384,13 +384,14 @@ async function mountInstallLocal(storageDir: string) {
};
}

/** The five doors, addressed with what each needs to act. */
/** The six doors, addressed with what each needs to act. */
const IL_DOORS = [
{ route: `POST ${IL}`, body: { manifest: APP } },
{ route: `GET ${IL}`, body: {} },
{ route: `DELETE ${IL}/:manifestId`, body: {} },
{ route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} },
{ route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} },
{ route: `GET ${IL}/:manifestId/sample-data`, body: {} },
] as const;

/** Install the app as the un-held operator, so the follow-on doors have a target. */
Expand All @@ -402,15 +403,15 @@ async function seedInstall(storageDir: string) {
}

describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => {
it('census: five routes, none allow-listed, all five covered below', async () => {
it('census: six routes, none allow-listed, all six covered below', async () => {
const { rawApp } = await mountInstallLocal(dir);
const keys = [...rawApp.routes.keys()].sort();
expect(keys).toEqual(IL_DOORS.map((d) => d.route).sort());
for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false);
});

for (const { user, gate } of HELD) {
it(`HELD (${gate.code}): all five doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => {
it(`HELD (${gate.code}): all six doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => {
await seedInstall(dir);
const ledgerBefore = existsSync(join(dir, LEDGER_FILE));
const { rawApp, effects } = await mountInstallLocal(dir);
Expand All @@ -430,6 +431,9 @@ describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => {
const list = await rawApp.routes.get(`GET ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member'));
expect(list.status).toBe(200);
expect(list.payload.success).toBe(true);
const counted = await rawApp.routes.get(`GET ${IL}/:manifestId/sample-data`)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id));
expect(counted.status, JSON.stringify(counted.payload)).toBe(200);
expect(counted.payload.success).toBe(true);
for (const route of [`POST ${IL}/:manifestId/reseed-sample-data`, `POST ${IL}/:manifestId/purge-sample-data`, `DELETE ${IL}/:manifestId`]) {
const res = await rawApp.routes.get(route)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id));
expect(res.status, `${route}: ${JSON.stringify(res.payload)}`).toBe(200);
Expand Down
11 changes: 11 additions & 0 deletions packages/cloud-connection/src/cloud-connection-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,17 @@ export const CLOUD_CONNECTION_ROUTE_LEDGER: readonly CloudConnectionRouteLedgerE
+ '`manage_metadata` (never merely a signed-in session) and driven from the Console. No `@objectstack/client` method builds this URL, and '
+ 'a bulk data-purge door is deliberately not something the application SDK exposes.',
},
{
route: 'GET /api/v1/marketplace/install-local/:manifestId/sample-data',
family: 'marketplace-install-local',
mountedIn: 'marketplace-install-local-plugin.ts',
disposition: 'server-only',
note:
'the read-only count beside purge: how many of the packaged app\'s sample rows the caller\'s scope still holds, '
+ 'counted by the purge\'s own matcher, so it is the number a purge would delete now. It deletes nothing and '
+ 'takes no query parameter. Behind `manage_metadata`, the purge\'s gate, since it exists to tell an operator '
+ 'what a purge would remove; no `@objectstack/client` method builds this URL, for the reason purge has none.',
},

// ── marketplace browse passthrough ─────────────────────────────────
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*
* ## What is pinned, through the booted plugin's own doors
*
* - a key whose owner holds `MFA_REQUIRED` (scope `account`) → all five doors
* - a key whose owner holds `MFA_REQUIRED` (scope `account`) → every door
* answer `403 MFA_REQUIRED` with the session's message, before any effect;
* - a key whose owner holds only `PASSWORD_RESET_REQUIRED` (scope
* `credential`) → served;
Expand Down Expand Up @@ -201,13 +201,14 @@ function withKey(body: unknown = {}, manifestId?: string) {
};
}

/** The five doors, addressed with what each needs to act. */
/** The six doors, addressed with what each needs to act. */
const IL_DOORS = [
{ route: `POST ${IL}`, body: { manifest: APP } },
{ route: `GET ${IL}`, body: {} },
{ route: `DELETE ${IL}/:manifestId`, body: {} },
{ route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} },
{ route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} },
{ route: `GET ${IL}/:manifestId/sample-data`, body: {} },
] as const;

let dir: string;
Expand All @@ -221,7 +222,7 @@ afterEach(() => {
});

describe('install-local doors — a key answers as its owner\'s session does (#23455, ruling C′)', () => {
it('REFUSES a key whose owner holds MFA_REQUIRED on all five doors, with the session\'s 403 code and message, before any effect', async () => {
it('REFUSES a key whose owner holds MFA_REQUIRED on all six doors, with the session\'s 403 code and message, before any effect', async () => {
const postures: Record<string, Posture[]> = {};
const m = await mountInstallLocal(dir, postures);
// Installed by the same key while its owner is not held, so the
Expand Down
114 changes: 113 additions & 1 deletion packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ import {
import { ConnectionCredentialStore } from './connection-credential-store.js';
import { readJsonRequestBody } from './request-json-body.js';
import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js';
import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js';
import { countSeedRows, matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js';
import { planCustomizationPairInstall, type PairInstallItem } from './marketplace-install-local-customization-pair.js';
import type {
AuthSessionApi, ConnectorCredentialRef, IAuthService, IAutomationService, IHttpServer, IMetadataService, IObjectQLEngine,
Expand Down Expand Up @@ -268,6 +268,7 @@ import { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type
// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge, step 1c''.
import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol';
import { resolveThrownHttpError } from '@objectstack/types';
import { unknownQueryParamRefusalMessage } from '@objectstack/rest';
// [#25366] The code the registry's security-catalog refusal carries, compared
// rather than `instanceof`-tested: the engine keeps the class unexported so a
// consumer holding the other build realm's copy cannot misread it.
Expand All @@ -278,6 +279,13 @@ type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>

const ROUTE_BASE = '/api/v1/marketplace/install-local';

/**
* The closed query-parameter set of `GET …/:manifestId/sample-data` (AGENTS.md,
* route rule 5): it reads none. An unknown name is refused with a located
* `400`, never ignored — the door has no dry-run switch to mistype.
*/
export const SAMPLE_DATA_COUNT_QUERY_PARAMS: readonly string[] = Object.freeze([]);

/**
* [#25366] Whether a `manifest.register` failure is the registry's security
* catalog refusal (`SecurityCatalogNameConflictError`, ADR-0048 §3.4 narrowed;
Expand Down Expand Up @@ -1240,9 +1248,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {

const reseedHandler = async (c: any) => this.handleReseed(c, ctx);
const purgeHandler = async (c: any) => this.handlePurge(c, ctx);
const sampleDataCountHandler = async (c: any) => this.handleSampleDataCount(c, ctx);

if (typeof rawApp.post === 'function') rawApp.post(ROUTE_BASE, postHandler);
if (typeof rawApp.get === 'function') rawApp.get(ROUTE_BASE, getHandler);
if (typeof rawApp.get === 'function') rawApp.get(`${ROUTE_BASE}/:manifestId/sample-data`, sampleDataCountHandler);
if (typeof rawApp.delete === 'function') rawApp.delete(`${ROUTE_BASE}/:manifestId`, deleteHandler);
if (typeof rawApp.post === 'function') {
rawApp.post(`${ROUTE_BASE}/:manifestId/reseed-sample-data`, reseedHandler);
Expand Down Expand Up @@ -4537,6 +4547,108 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}, 200);
};

/**
* GET /api/v1/marketplace/install-local/:manifestId/sample-data
*
* How many of the package's sample rows the caller's scope still holds:
* `{ manifestId, count, unidentified }`. `count` is the number of rows a
* purge run now would delete — {@link countSeedRows}, the purge's own
* {@link matchSeedRows} pass with nothing deleted — so the console can say
* "N records will go" after a reload, when the install answer's
* `data.seeded` is long gone. `unidentified` is the seed records the purge
* would report in `errors` and leave in place.
*
* A read of its own, not a switch on the purge: a GET can delete nothing,
* whatever the request carries, and it takes no query parameter at all
* ({@link SAMPLE_DATA_COUNT_QUERY_PARAMS}). Not a field on the listing
* either: the listing answers `withSampleData` for every installed package
* on every visit, from the FIRST seed row it finds; a count reads every
* seeded object's rows, so it is asked for one package, when it is needed.
*
* Every answer is the purge's for the same request, read-only:
* • the purge's gate, `manage_metadata` ({@link requireInstallCapability}),
* since the count exists to word the purge's confirm;
* • `404` for no install, the unreadable-entry answer, and the protocol
* handshake's `422 OS_PROTOCOL_INCOMPATIBLE` (#21834), in that order;
* • the purge's scope: under an organization wall the caller's active
* organization, table-wide without one. A walled session with NO active
* organization reads nothing and answers `count: 0` — ADR-0123 D2's
* answer for a tenant-scoped READ, the listing's `withSampleData: false`
* for the same session; the purge it would word refuses that session;
* • a package that declares no seed datasets answers `count: 0` (the
* purge's `400 NOTHING_TO_PURGE` is about acting, and nothing is acted on);
* • no data engine or metadata service: the purge's `500
* DRIVER_UNAVAILABLE`, never a `0` that reads as "nothing left".
*/
private handleSampleDataCount = async (c: any, ctx: PluginContext): Promise<Response> => {
const admission = await this.requireInstallCapability(c, ctx, 'Counting sample data');
if (!admission.ok) return admission.response;
const query = typeof c.req?.query === 'function' ? c.req.query() : undefined;
const queryRefusal = unknownQueryParamRefusalMessage(query, SAMPLE_DATA_COUNT_QUERY_PARAMS);
if (queryRefusal) {
return c.json({ success: false, error: { code: 'VALIDATION_ERROR', message: queryRefusal } }, 400);
}
const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim();
if (!manifestId) {
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'manifestId path param required.' } }, 400);
}
if (!this.ledger.has(manifestId)) {
return c.json({ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: `No marketplace install for ${manifestId}.` } }, 404);
}
const { entry, failure } = this.ledger.read(manifestId);
if (!entry) {
return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'count sample data');
}
const incompatible = this.refuseProtocolIncompatibleEntry(c, entry);
if (incompatible) return incompatible;

const answer = (count: number, unidentified: number): Response => c.json({
success: true,
data: { manifestId, count, unidentified },
}, 200);

const datasets = seedDatasetsOf(entry.manifest);
if (datasets.length === 0) return answer(0, 0);

let ql: IObjectQLEngine | undefined;
let metadata: IMetadataService | undefined;
try { ql = ctx.getService<IObjectQLEngine>('objectql'); } catch { /* no data engine */ }
try { metadata = ctx.getService<IMetadataService>('metadata'); } catch { /* no metadata service */ }
if (!ql || !metadata) {
return c.json({
success: false,
error: {
code: 'DRIVER_UNAVAILABLE',
message: 'The data engine (objectql) or the metadata service is not available on this runtime — cannot count sample data.',
},
}, 500);
}

let organizationId: string | undefined;
if (organizationWallActive(ctx)) {
const resolved = await this.resolveActiveOrgId(c, ctx);
// [ADR-0123 D2] No organization, so no organization's rows to count.
if (!resolved) return answer(0, 0);
organizationId = resolved;
}

const { SeedLoaderService } = await import('@objectstack/runtime');
const loader = new (SeedLoaderService as any)(ql, metadata, ctx.logger);
const graph = await loader.buildDependencyGraph([...new Set(datasets.map((d: any) => String(d.object)))]);
const { count, unidentified } = await countSeedRows({
engine: ql,
datasets,
graph,
organizationId,
report: (problem) => ctx.logger?.warn?.(
`[MarketplaceInstallLocal] ${manifestId}: the sample-data count could not identify `
+ `${problem.records} seed record(s) of ${problem.object} (${problem.kind}: ${problem.detail}); `
+ 'a purge would leave them in place',
),
});
return answer(count, unidentified);
};

/**
* [#21834] ADR-0087 D1's handshake on the two sample-data doors: the answer
* reseed and purge give a ledger entry whose declared protocol range
Expand Down
Loading
Loading