@bakers-build/addon-workspace 0.0.0-stage → 0.1.0-next.42

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-workspace
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build connection addon: fills the ambient workspace (bb_workspace) into a create payload transparently, via dropsh's alterRequest hook.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,82 @@
1
+ import type { DropSHPlugin } from 'dropsh/plugin';
2
+ /**
3
+ * The entity types whose workspace is a DIRECT `workspace_id` field, and which
4
+ * this addon may therefore fill from context.
5
+ *
6
+ * `bb_project` and `bb_ticket` are deliberately ABSENT. Their workspace
7
+ * anchor is `group_id` / `project_id` — a genuine choice the caller must make
8
+ * (which group? which project?), not an ambient fact. The server narrows the
9
+ * offer for those two (`GroupInActiveWorkspaceSelection` /
10
+ * `ProjectInActiveWorkspaceSelection`) and `BbWorkspaceScope` gates the write;
11
+ * guessing one here would be inventing data, not supplying context.
12
+ *
13
+ * `bb_term` joined in BB-339. It meets the criterion exactly — a direct
14
+ * `workspace_id`, and which workspace a label belongs to is ambient rather than
15
+ * chosen — and it is not merely a convenience: `@bakers-build/qualify-ticket` creates a
16
+ * missing `work:*` term through dropsh, so without this entry that step answers
17
+ * 422 on `workspace_id` on every instance where the term is not seeded yet.
18
+ */
19
+ export declare const DEFAULT_WORKSPACE_ENTITY_TYPES: readonly string[];
20
+ /**
21
+ * Entity type → the JSON:API field path from it to the `bb_workspace` uuid.
22
+ *
23
+ * This is a SECOND list beside DEFAULT_WORKSPACE_ENTITY_TYPES and not an
24
+ * extension of it, because the two answer different questions. That one asks
25
+ * "is the workspace an ambient fact on a create?" and excludes bb_project and
26
+ * bb_ticket precisely because choosing their group/project is the caller's
27
+ * decision. This one asks "how is this type reachable from a workspace?", and
28
+ * for those two the answer exists — it is just longer.
29
+ *
30
+ * `bb_workspace` itself is deliberately ABSENT (BB-472 live verification):
31
+ * a `bb_workspace` collection search is exactly how `discoverBbWorkspaces()`
32
+ * lists what an identity can switch to, and narrowing it to
33
+ * `id = <the already-configured workspace>` would make every reachable
34
+ * workspace but the current one invisible — defeating the switch this ticket
35
+ * builds. Server-side membership (F2) already bounds that collection to what
36
+ * the identity may see; this client-side layer answers "which one is
37
+ * configured for creates/other reads", a question that does not apply to the
38
+ * workspace list itself.
39
+ */
40
+ export declare const WORKSPACE_READ_SCOPE_PATHS: Readonly<Record<string, string | readonly string[]>>;
41
+ /**
42
+ * BB-495 D5: every UI collection read that is NOT workspace-scoped by
43
+ * {@link WORKSPACE_READ_SCOPE_PATHS} and is, deliberately, not a gap to close
44
+ * here — closing one would be its own behaviour change and its own scope. The
45
+ * completeness gate (`addons/bakers-ui/tests/workspace-scope-coverage.test.ts`)
46
+ * derives every collection the UI reads from its source tree and fails on any
47
+ * that is neither covered above nor named here, so a new unscoped read is
48
+ * caught rather than silently added to an ever-growing exception list.
49
+ */
50
+ export declare const UI_COLLECTION_EXCEPTIONS: readonly string[];
51
+ /** Options for the workspace addon — normally the connection config's globals. */
52
+ export interface WorkspaceOptions {
53
+ /**
54
+ * The UUID of the `bb_workspace` every create is bound to. REQUIRED at the
55
+ * top level of the connection config; `loadBbConfig` rejects a config
56
+ * without it, so by the time this addon runs the value is present.
57
+ */
58
+ workspace_id?: string;
59
+ /**
60
+ * Override the entity types whose `workspace_id` is filled from context.
61
+ * Defaults to `DEFAULT_WORKSPACE_ENTITY_TYPES`; configurable so a site that
62
+ * adds a further directly-bound type does not need a new addon release.
63
+ */
64
+ entity_types?: string[];
65
+ }
66
+ /** No workspace configured, but a create needed one. Named so `PluginError` reads. */
67
+ export declare class WorkspaceConfigError extends Error {
68
+ readonly name = "WorkspaceConfigError";
69
+ }
70
+ /** The create body was not parseable JSON — fail loudly, never half-parse. */
71
+ export declare class WorkspacePayloadError extends Error {
72
+ readonly name = "WorkspacePayloadError";
73
+ }
74
+ /**
75
+ * The bakers.build workspace plugin. Constructed by the `./preset` accumulator from the
76
+ * connection config's global options (via `@bakers-build/addon-essentials`, which
77
+ * lists this addon as a preset child), or directly by dropsh from a legacy
78
+ * `plugins: []` descriptor (this module default-exports the factory, so no
79
+ * `export:` is needed either way).
80
+ */
81
+ export declare function workspacePlugin(opts?: WorkspaceOptions): DropSHPlugin;
82
+ export default workspacePlugin;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The entity types whose workspace is a DIRECT `workspace_id` field, and which
3
+ * this addon may therefore fill from context.
4
+ *
5
+ * `bb_project` and `bb_ticket` are deliberately ABSENT. Their workspace
6
+ * anchor is `group_id` / `project_id` — a genuine choice the caller must make
7
+ * (which group? which project?), not an ambient fact. The server narrows the
8
+ * offer for those two (`GroupInActiveWorkspaceSelection` /
9
+ * `ProjectInActiveWorkspaceSelection`) and `BbWorkspaceScope` gates the write;
10
+ * guessing one here would be inventing data, not supplying context.
11
+ *
12
+ * `bb_term` joined in BB-339. It meets the criterion exactly — a direct
13
+ * `workspace_id`, and which workspace a label belongs to is ambient rather than
14
+ * chosen — and it is not merely a convenience: `@bakers-build/qualify-ticket` creates a
15
+ * missing `work:*` term through dropsh, so without this entry that step answers
16
+ * 422 on `workspace_id` on every instance where the term is not seeded yet.
17
+ */
18
+ export const DEFAULT_WORKSPACE_ENTITY_TYPES = [
19
+ 'bb_group',
20
+ 'bb_team',
21
+ 'bb_term',
22
+ ];
23
+ /**
24
+ * Entity type → the JSON:API field path from it to the `bb_workspace` uuid.
25
+ *
26
+ * This is a SECOND list beside DEFAULT_WORKSPACE_ENTITY_TYPES and not an
27
+ * extension of it, because the two answer different questions. That one asks
28
+ * "is the workspace an ambient fact on a create?" and excludes bb_project and
29
+ * bb_ticket precisely because choosing their group/project is the caller's
30
+ * decision. This one asks "how is this type reachable from a workspace?", and
31
+ * for those two the answer exists — it is just longer.
32
+ *
33
+ * `bb_workspace` itself is deliberately ABSENT (BB-472 live verification):
34
+ * a `bb_workspace` collection search is exactly how `discoverBbWorkspaces()`
35
+ * lists what an identity can switch to, and narrowing it to
36
+ * `id = <the already-configured workspace>` would make every reachable
37
+ * workspace but the current one invisible — defeating the switch this ticket
38
+ * builds. Server-side membership (F2) already bounds that collection to what
39
+ * the identity may see; this client-side layer answers "which one is
40
+ * configured for creates/other reads", a question that does not apply to the
41
+ * workspace list itself.
42
+ */
43
+ export const WORKSPACE_READ_SCOPE_PATHS = {
44
+ bb_group: 'workspace_id.id',
45
+ bb_team: 'workspace_id.id',
46
+ // bb_machine is account-owned: it has no workspace_id, and
47
+ // machineCapacity() must not send one. Isolation is owner_user_id on the
48
+ // server, not this client filter.
49
+ bb_term: 'workspace_id.id',
50
+ bb_project: 'group_id.workspace_id.id',
51
+ bb_ticket: 'project_id.group_id.workspace_id.id',
52
+ bb_environment: 'project_id.group_id.workspace_id.id',
53
+ bb_conductor: 'project_id.group_id.workspace_id.id',
54
+ // Exactly one of project and workspace is set on a template: either path
55
+ // admits it (an OR group).
56
+ bb_ticket_template: [
57
+ 'project_id.group_id.workspace_id.id',
58
+ 'workspace_id.id',
59
+ ],
60
+ bb_run: 'ticket_id.project_id.group_id.workspace_id.id',
61
+ bb_comment: 'ticket_id.project_id.group_id.workspace_id.id',
62
+ };
63
+ /**
64
+ * BB-495 D5: every UI collection read that is NOT workspace-scoped by
65
+ * {@link WORKSPACE_READ_SCOPE_PATHS} and is, deliberately, not a gap to close
66
+ * here — closing one would be its own behaviour change and its own scope. The
67
+ * completeness gate (`addons/bakers-ui/tests/workspace-scope-coverage.test.ts`)
68
+ * derives every collection the UI reads from its source tree and fails on any
69
+ * that is neither covered above nor named here, so a new unscoped read is
70
+ * caught rather than silently added to an ever-growing exception list.
71
+ */
72
+ export const UI_COLLECTION_EXCEPTIONS = [
73
+ // `user` is not a bakers.build entity type — `WorkspaceReadScopeSubscriber`
74
+ // registers nothing for it (it hangs off `entity.query_access.<type>` for
75
+ // bakers.build types only), so `WORKSPACE_READ_SCOPE_PATHS` cannot list it either.
76
+ 'user',
77
+ // A bespoke resource, not an entity query — `Entity/user/Data/users.ts:41`
78
+ // already called it "unscoped statistics" before this gate existed.
79
+ 'bakers/statistics',
80
+ // OG core's own membership resource, not a bakers.build entity type either — the
81
+ // same class of gap as `user`, for the same subscriber-registration reason.
82
+ 'og_membership',
83
+ // Deliberately never scoped, not merely unregistered: `bb_workspace`
84
+ // itself IS the reachable-workspace list — narrowing it to the already-
85
+ // configured workspace would make every workspace but the current one
86
+ // invisible, defeating the picker/switch this collection exists for.
87
+ // Server-side membership already bounds it to what the identity may see.
88
+ 'bb_workspace',
89
+ // A bespoke resource keyed by ONE ticket's UUID, gated on view access to that
90
+ // ticket — the ticket already sits in the workspace it belongs to, so there
91
+ // is no collection to narrow.
92
+ 'bakers/addons/ticket',
93
+ ];
94
+ /** The filter group name. Unique by construction, so a caller filter cannot collide. */
95
+ const READ_SCOPE_FILTER = 'bb_ambient_workspace';
96
+ /** The JSON:API resource type of the workspace a `workspace_id` names. */
97
+ const WORKSPACE_RESOURCE_TYPE = 'bb_workspace--bb_workspace';
98
+ /** The relationship this addon fills. */
99
+ const WORKSPACE_FIELD = 'workspace_id';
100
+ /** No workspace configured, but a create needed one. Named so `PluginError` reads. */
101
+ export class WorkspaceConfigError extends Error {
102
+ name = 'WorkspaceConfigError';
103
+ }
104
+ /** The create body was not parseable JSON — fail loudly, never half-parse. */
105
+ export class WorkspacePayloadError extends Error {
106
+ name = 'WorkspacePayloadError';
107
+ }
108
+ function isRecord(v) {
109
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
110
+ }
111
+ /**
112
+ * The bakers.build workspace plugin. Constructed by the `./preset` accumulator from the
113
+ * connection config's global options (via `@bakers-build/addon-essentials`, which
114
+ * lists this addon as a preset child), or directly by dropsh from a legacy
115
+ * `plugins: []` descriptor (this module default-exports the factory, so no
116
+ * `export:` is needed either way).
117
+ */
118
+ export function workspacePlugin(opts = {}) {
119
+ const workspaceId = typeof opts.workspace_id === 'string' ? opts.workspace_id.trim() : '';
120
+ const entityTypes = new Set(opts.entity_types ?? [...DEFAULT_WORKSPACE_ENTITY_TYPES]);
121
+ return {
122
+ id: 'bakers-workspace',
123
+ requiredModules: [],
124
+ async alterRequest(req, ctx) {
125
+ // Reads: narrow a COLLECTION to the configured workspace. `read` (an
126
+ // individual resource by uuid) is deliberately absent — JSON:API ignores
127
+ // filters on individual endpoints, so appending one there would be code that
128
+ // lies about what it does. Isolation on that path stays the server's.
129
+ if (ctx.operation === 'search') {
130
+ const path = ctx.entityType
131
+ ? WORKSPACE_READ_SCOPE_PATHS[ctx.entityType]
132
+ : undefined;
133
+ if (workspaceId === '' || path === undefined)
134
+ return req;
135
+ const url = new URL(req.url);
136
+ const condition = (name, conditionPath) => {
137
+ url.searchParams.set(`filter[${name}][condition][path]`, conditionPath);
138
+ url.searchParams.set(`filter[${name}][condition][operator]`, '=');
139
+ url.searchParams.set(`filter[${name}][condition][value]`, workspaceId);
140
+ };
141
+ if (typeof path === 'string') {
142
+ condition(READ_SCOPE_FILTER, path);
143
+ }
144
+ else {
145
+ url.searchParams.set(`filter[${READ_SCOPE_FILTER}][group][conjunction]`, 'OR');
146
+ path.forEach((alternative, i) => {
147
+ condition(`${READ_SCOPE_FILTER}_${i}`, alternative);
148
+ url.searchParams.set(`filter[${READ_SCOPE_FILTER}_${i}][condition][memberOf]`, READ_SCOPE_FILTER);
149
+ });
150
+ }
151
+ return { ...req, url: url.toString() };
152
+ }
153
+ // (1) Creates only. An `update` that re-binds an existing row to another
154
+ // workspace is a real decision governed by the server's own rule — never
155
+ // something to slip in ambiently — so PATCH is passed through untouched.
156
+ if (ctx.operation !== 'create')
157
+ return req;
158
+ // (2) Only the types whose workspace is a direct `workspace_id` field.
159
+ if (ctx.entityType === undefined || !entityTypes.has(ctx.entityType)) {
160
+ return req;
161
+ }
162
+ // (3) `upload` bodies are binary; nothing to parse.
163
+ if (typeof req.body !== 'string')
164
+ return req;
165
+ // (6) A create that needs a workspace with none configured must not go out
166
+ // unbound — the server would answer 422 and the cause would be invisible.
167
+ // dropsh wraps this in a PluginError carrying `bakers-workspace`. Normally
168
+ // unreachable: `workspace_id` is required at config load.
169
+ if (workspaceId === '') {
170
+ throw new WorkspaceConfigError(`@bakers-build/addon-workspace: creating a "${ctx.entityType}" needs an ambient workspace, but none is configured. ` +
171
+ `Set the top-level \`${WORKSPACE_FIELD}: '<bb_workspace uuid>'\` in your connection config (.bakers/bakers.config.js).`);
172
+ }
173
+ // (7) Malformed JSON must fail by name, never be swallowed into a request
174
+ // that is half-rewritten.
175
+ let doc;
176
+ try {
177
+ doc = JSON.parse(req.body);
178
+ }
179
+ catch (cause) {
180
+ throw new WorkspacePayloadError(`@bakers-build/addon-workspace: the "${ctx.entityType}" create body is not valid JSON, so the ambient workspace could not be applied: ${String(cause)}`);
181
+ }
182
+ if (!isRecord(doc) || !isRecord(doc.data))
183
+ return req;
184
+ const relationships = isRecord(doc.data.relationships)
185
+ ? doc.data.relationships
186
+ : undefined;
187
+ // (4) An explicit value ALWAYS wins over the ambient one.
188
+ if (relationships && WORKSPACE_FIELD in relationships)
189
+ return req;
190
+ // (5) Inject. Headers are untouched by construction — only `body` is
191
+ // replaced, so `Authorization` (applied before this hook) survives verbatim.
192
+ const next = {
193
+ ...doc,
194
+ data: {
195
+ ...doc.data,
196
+ relationships: {
197
+ ...(relationships ?? {}),
198
+ [WORKSPACE_FIELD]: {
199
+ data: { type: WORKSPACE_RESOURCE_TYPE, id: workspaceId },
200
+ },
201
+ },
202
+ },
203
+ };
204
+ return { ...req, body: JSON.stringify(next) };
205
+ },
206
+ };
207
+ }
208
+ export default workspacePlugin;
@@ -0,0 +1,3 @@
1
+ import type { BbPreset } from '@bakers-build/core';
2
+ import { type WorkspaceOptions } from './index.js';
3
+ export declare const connectionPlugins: BbPreset<WorkspaceOptions>['connectionPlugins'];
@@ -0,0 +1,8 @@
1
+ import { workspacePlugin } from './index.js';
2
+ export const connectionPlugins = (acc, opts) => {
3
+ const o = (opts ?? {});
4
+ if (typeof o.workspace_id !== 'string' || o.workspace_id.trim() === '') {
5
+ return acc;
6
+ }
7
+ return [...acc, workspacePlugin(o)];
8
+ };
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-workspace",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.42",
4
+ "description": "bakers.build connection addon: fills the ambient workspace (bb_workspace) into a create payload transparently, via dropsh's alterRequest hook.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js",
9
+ "./preset": "./dist/src/preset.js"
10
+ },
11
+ "files": [
12
+ "dist/src"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
20
+ "directory": "bakers-cli/addons/workspace"
21
+ },
22
+ "dependencies": {
23
+ "dropsh": "^0.7.5"
24
+ },
25
+ "peerDependencies": {
26
+ "@bakers-build/core": "^0.1.0-next.42"
27
+ }
28
+ }