@bongos/core 1.19.513 → 1.19.515
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/.bongos-core.json +62 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +20 -0
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0182-project-visibility-two-axes.md +2 -0
- package/docs/api/openapi.json +351 -4
- package/docs/api-reference.md +12 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
- package/modules/government/catalog.js +6 -0
- package/modules/government/migrations/government_009_mingle_manage.sql +55 -0
- package/modules/lifecycle/migrations/lifecycle_007_mingle.sql +134 -0
- package/modules/lifecycle/mingle.js +337 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/mingle.js +116 -0
- package/modules/platform-identity/platform-identity.js +24 -20
- package/modules/platform-identity/projects-feed.js +142 -0
- package/modules/platform-identity/routes/projects.js +27 -8
- package/modules/platform-identity/tests/platform-identity.mjs +9 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_seed.mjs +35 -0
- package/tests/mingle.mjs +370 -0
- package/tests/projects_hub.mjs +5 -1
- package/tests/visible_projects_feed.mjs +320 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// modules/platform-identity/projects-feed.js — THE public projects feed: WHO is on
|
|
2
|
+
// the map, and WHAT each row is allowed to say (R06, task 1002325; ADR 0182 D1/D2a/D3).
|
|
3
|
+
// The in-repo lever the orbs page reads. The publish-gate.js precedent — one question,
|
|
4
|
+
// one file, so no two surfaces can answer it differently.
|
|
5
|
+
//
|
|
6
|
+
// THREE ANSWERS, and they belong together because each is only safe given the others:
|
|
7
|
+
//
|
|
8
|
+
// • WHO — `listPublicProjects`. A project is on the map iff `status = 'active' AND
|
|
9
|
+
// publishable`, the derived identity gate, taken from publishableSql() so this feed
|
|
10
|
+
// and the curation list read ONE definition. It used to be `featured = true`, which
|
|
11
|
+
// made Archon curation load-bearing for visibility at all: an owner could set their
|
|
12
|
+
// project public and see nothing happen (ADR 0182 D3). `featured` keeps the only job
|
|
13
|
+
// D3 leaves it — RANKING among rows already on the map. Dark matter is excluded HERE,
|
|
14
|
+
// in SQL, because a client-side filter ships the husk to anyone who opens a network tab.
|
|
15
|
+
//
|
|
16
|
+
// • WHAT IT DRAWS AS — `orbForState`, the ADR 0182 D1 mapping:
|
|
17
|
+
// public → planet (identified, anyone may join)
|
|
18
|
+
// private → star (identified, no self-join, applications accepted)
|
|
19
|
+
// stealth → black hole (ANONYMOUS, no self-join, applications refused)
|
|
20
|
+
// Three surfaces render it — this feed, Explore (R07, task 1002326) and the static
|
|
21
|
+
// orbs site (R09, task 1002328) — and the last is a different repo. A mapping copied
|
|
22
|
+
// into each is a mapping that drifts.
|
|
23
|
+
//
|
|
24
|
+
// • WHAT IT MAY SAY — `feedShapeFor`, the D2a privacy boundary in code: a stealth
|
|
25
|
+
// project exposes EXISTENCE, never IDENTITY. Redaction is server-side because the
|
|
26
|
+
// naive version sends the row and lets the orbs page hide it, which leaks the whole
|
|
27
|
+
// project to anyone who opens the network tab — and that page is a static site
|
|
28
|
+
// outside this repo, so we could not enforce it there even in principle.
|
|
29
|
+
//
|
|
30
|
+
// TWO DELIBERATE FAIL-CLOSED CHOICES, because this is a privacy boundary, not a
|
|
31
|
+
// rendering detail:
|
|
32
|
+
// 1. AN UNKNOWN STATE DRAWS AS A BLACK HOLE. A fourth enum value added later and not
|
|
33
|
+
// mapped here would otherwise ship its project's name as a planet.
|
|
34
|
+
// 2. THE STEALTH SHAPE IS AN ALLOW-LIST, NOT A DENY-LIST — everything is nulled except
|
|
35
|
+
// the two keys a black hole may carry, so a column added to the feed next year is
|
|
36
|
+
// anonymous without anyone remembering to come back here. A deny-list of "the
|
|
37
|
+
// identifying fields" is correct exactly until the feed grows.
|
|
38
|
+
//
|
|
39
|
+
// WHY ITS OWN FILE. The account-visibility.js / publish-gate.js reason: platform-identity.js
|
|
40
|
+
// sits at the file-size ratchet. The caller-facing symbols (listPublicProjects, feedShapeFor,
|
|
41
|
+
// orbForState, isStealthProject, ORB_BY_STATE) are re-exported there so the module's public
|
|
42
|
+
// surface is unchanged; orbStateOf, FALLBACK_PROJECT_STATE and STEALTH_ANONYMOUS_KEEPS are
|
|
43
|
+
// this file's own internals and are reached only from here and its test.
|
|
44
|
+
// Proof: ../../tests/visible_projects_feed.mjs
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const { pool: defaultPool } = require('../../src/module-api');
|
|
48
|
+
const { publishableSql } = require('./publish-gate');
|
|
49
|
+
|
|
50
|
+
// ADR 0182 D1's mapping, and the only copy of it in the repo. The three words are locked
|
|
51
|
+
// by goal 1000046's `proj-visibility-states` criterion, not chosen here.
|
|
52
|
+
const ORB_BY_STATE = Object.freeze({
|
|
53
|
+
public: 'planet',
|
|
54
|
+
private: 'star',
|
|
55
|
+
stealth: 'black-hole',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// What an unrecognised (or absent) stored value is treated as — the ANONYMOUS state,
|
|
59
|
+
// deliberately. A project whose door we cannot read is one whose name we must not publish.
|
|
60
|
+
const FALLBACK_PROJECT_STATE = 'stealth';
|
|
61
|
+
|
|
62
|
+
// The ONLY keys a black hole may carry; everything else on the row is nulled. No stable
|
|
63
|
+
// per-project key appears here on purpose: an id, a slug or a hash of the origin would
|
|
64
|
+
// each let an observer who kept yesterday's public feed name the project that went stealth
|
|
65
|
+
// today. Black holes are interchangeable, which is the whole point of the metaphor.
|
|
66
|
+
const STEALTH_ANONYMOUS_KEEPS = Object.freeze(['state', 'orb']);
|
|
67
|
+
|
|
68
|
+
// The stored join door, normalised to a state this file knows. Never echoes an
|
|
69
|
+
// unvalidated value onto the wire.
|
|
70
|
+
function orbStateOf(visibility) {
|
|
71
|
+
const v = String(visibility ?? '').trim();
|
|
72
|
+
return Object.prototype.hasOwnProperty.call(ORB_BY_STATE, v) ? v : FALLBACK_PROJECT_STATE;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The render type for a state — the one mapping R07 and R09 both read. PURE.
|
|
76
|
+
function orbForState(state) {
|
|
77
|
+
return ORB_BY_STATE[orbStateOf(state)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Is this row anonymous on the map? Exported so the feed route can keep a stealth
|
|
81
|
+
// project's origin out of the per-project enrichment fetches entirely — asking its own
|
|
82
|
+
// public API for its builders' avatars would both put identity on the wire and tell that
|
|
83
|
+
// project it was being enumerated.
|
|
84
|
+
function isStealthProject(project) {
|
|
85
|
+
return orbStateOf(project?.visibility) === 'stealth';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ONE catalog row → the public wire row. Spends `visibility` deriving `state` + `orb` (it
|
|
89
|
+
// never doubles it on the wire), then either passes the identified row through or rebuilds
|
|
90
|
+
// it from the allow-list above. The redaction NULLS rather than DELETES: the feed's
|
|
91
|
+
// standing contract is that an unanswerable value is null and never absent, because
|
|
92
|
+
// JSON.stringify drops the absent one and a client would read `undefined` on some rows and
|
|
93
|
+
// null on others. A redacted row is a full-width row that says nothing. PURE.
|
|
94
|
+
function feedShapeFor(project) {
|
|
95
|
+
const { visibility, ...rest } = project || {};
|
|
96
|
+
const state = orbStateOf(visibility);
|
|
97
|
+
const shaped = { ...rest, state, orb: ORB_BY_STATE[state] };
|
|
98
|
+
if (state !== 'stealth') return shaped;
|
|
99
|
+
const anonymous = {};
|
|
100
|
+
for (const key of Object.keys(shaped)) {
|
|
101
|
+
anonymous[key] = STEALTH_ANONYMOUS_KEEPS.includes(key) ? shaped[key] : null;
|
|
102
|
+
}
|
|
103
|
+
return anonymous;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The feed's hard ceiling. Until R06 the listing was bounded by an Archon choosing to
|
|
107
|
+
// feature a project; the swap to `publishable` replaced that human bound with self-service,
|
|
108
|
+
// on a route that is public, CORS-open, unauthenticated AND drives one outbound stats fetch
|
|
109
|
+
// per row. A cap is the cheap half of that trade — the honest half is pagination, which is
|
|
110
|
+
// its own task; this constant only stops the tail from being unbounded meanwhile. Set far
|
|
111
|
+
// above the live catalog, so it truncates nothing today.
|
|
112
|
+
const PUBLIC_FEED_MAX_PROJECTS = 500;
|
|
113
|
+
|
|
114
|
+
// Every project ON THE MAP, in the public projection (no owner_github_id, no status).
|
|
115
|
+
// `visibility` is projected because feedShapeFor() derives each row's state + orb from it;
|
|
116
|
+
// it is spent there and never reaches the wire. created_at is the LISTED date — when the
|
|
117
|
+
// hub first catalogued the row, not when the project began (task 1002778). updated_at may
|
|
118
|
+
// sort but never projects: feature toggles and client re-registration bump it, so as a
|
|
119
|
+
// public "last updated" it would lie. `description` is the publish BAR, spent in the
|
|
120
|
+
// predicate, never a card field.
|
|
121
|
+
async function listPublicProjects({ pool = defaultPool } = {}) {
|
|
122
|
+
const { rows } = await pool.query(
|
|
123
|
+
`SELECT origin, name, tagline, art_url, type, template, created_at, visibility
|
|
124
|
+
FROM platform_identity_projects
|
|
125
|
+
WHERE status = 'active' AND ${publishableSql()}
|
|
126
|
+
ORDER BY featured DESC, updated_at DESC
|
|
127
|
+
LIMIT ${PUBLIC_FEED_MAX_PROJECTS}`,
|
|
128
|
+
);
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
ORB_BY_STATE,
|
|
134
|
+
FALLBACK_PROJECT_STATE,
|
|
135
|
+
STEALTH_ANONYMOUS_KEEPS,
|
|
136
|
+
orbStateOf,
|
|
137
|
+
orbForState,
|
|
138
|
+
isStealthProject,
|
|
139
|
+
feedShapeFor,
|
|
140
|
+
PUBLIC_FEED_MAX_PROJECTS,
|
|
141
|
+
listPublicProjects,
|
|
142
|
+
};
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// modules/platform-identity/routes/projects.js — the hub projects catalog + the
|
|
2
2
|
// curated public Explore showcase (ADR 0141 §5, task #1002110).
|
|
3
3
|
//
|
|
4
|
-
// GET /projects/featured — PUBLIC
|
|
4
|
+
// GET /projects/featured — PUBLIC projects feed: every project on the map
|
|
5
|
+
// (active + publishable), each carrying its `state`
|
|
6
|
+
// and `orb` render type, stealth rows anonymised
|
|
7
|
+
// server-side, dark matter excluded (ADR 0182 D2a/D3).
|
|
8
|
+
// The PATH keeps the old word: it is a rank:public,
|
|
9
|
+
// CORS-open URL that a static site and other origins
|
|
10
|
+
// already fetch, and renaming it would break them for
|
|
11
|
+
// nothing. `featured` now only RANKS the feed.
|
|
5
12
|
// GET /projects — Archon: full curation list (+ featured flags, the stored
|
|
6
13
|
// join door, and the DERIVED publish verdict — ADR 0182)
|
|
7
14
|
// POST /projects — Archon: create / update a catalog entry
|
|
@@ -141,13 +148,20 @@ module.exports = function projectsRoutes() {
|
|
|
141
148
|
}
|
|
142
149
|
});
|
|
143
150
|
|
|
144
|
-
// The PUBLIC
|
|
145
|
-
// CORS-enabled cross-origin read (a status page / the apex
|
|
151
|
+
// The PUBLIC projects feed — every project ON THE MAP (active + publishable),
|
|
152
|
+
// public projection. CORS-enabled cross-origin read (a status page / the apex /
|
|
153
|
+
// the static orbs site fetches it pre-auth).
|
|
146
154
|
router.use('/projects/featured', corsPublicGet('/projects/featured'));
|
|
147
|
-
// rank: public —
|
|
155
|
+
// rank: public — the public map of projects; no auth by design.
|
|
148
156
|
router.get('/projects/featured', async (_req, res) => {
|
|
149
157
|
try {
|
|
150
|
-
const projects = await pi.
|
|
158
|
+
const projects = await pi.listPublicProjects({ pool });
|
|
159
|
+
// A stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its origin
|
|
160
|
+
// is kept out of both enrichments below: asking its own public API for its
|
|
161
|
+
// builders and their avatars would put identity on the wire, and tell that
|
|
162
|
+
// project it was being enumerated. feedShapeFor() redacts the row either
|
|
163
|
+
// way — this is the half that never makes the request at all.
|
|
164
|
+
const identified = projects.filter((p) => !pi.isStealthProject(p));
|
|
151
165
|
// Builders + tags come from each project's OWN public API, because the
|
|
152
166
|
// apex catalog does not know them and the membership tables would report
|
|
153
167
|
// zero for every hand-wired project (task 1002771; see project-stats.js).
|
|
@@ -156,7 +170,7 @@ module.exports = function projectsRoutes() {
|
|
|
156
170
|
// listing — a broken stats fetch cannot take the catalog down with it.
|
|
157
171
|
let stats = {};
|
|
158
172
|
try {
|
|
159
|
-
stats = await projectStats.getProjectStats(
|
|
173
|
+
stats = await projectStats.getProjectStats(identified.map((p) => p.origin));
|
|
160
174
|
} catch (err) {
|
|
161
175
|
console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
|
|
162
176
|
}
|
|
@@ -168,7 +182,7 @@ module.exports = function projectsRoutes() {
|
|
|
168
182
|
// a failed read leaves every project on the platform's own pick.
|
|
169
183
|
let planets = {};
|
|
170
184
|
try {
|
|
171
|
-
planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(
|
|
185
|
+
planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(identified.map((p) => hostOfOrigin(p.origin)));
|
|
172
186
|
} catch (err) {
|
|
173
187
|
log.warn('project planets enrichment failed (non-fatal):', err.message);
|
|
174
188
|
}
|
|
@@ -183,8 +197,13 @@ module.exports = function projectsRoutes() {
|
|
|
183
197
|
// catalog's; a project that did not answer carries the ONE shared null
|
|
184
198
|
// shape, so the wire never drops a key. art_url is catalog text an Archon
|
|
185
199
|
// typed — scheme-checked here, and again on the client, before it is a src.
|
|
200
|
+
// feedShapeFor() is the LAST thing each row passes through, deliberately: it
|
|
201
|
+
// derives `state` + `orb` from the stored door and, for a stealth project,
|
|
202
|
+
// rebuilds the row from an allow-list of the two keys a black hole may carry
|
|
203
|
+
// (ADR 0182 D2a). Redacting any earlier would be silently undone by the
|
|
204
|
+
// spreads above.
|
|
186
205
|
res.json({
|
|
187
|
-
projects: projects.map((p) => ({
|
|
206
|
+
projects: projects.map((p) => pi.feedShapeFor({
|
|
188
207
|
...p,
|
|
189
208
|
art_url: projectStats.httpUrl(p.art_url, 2048),
|
|
190
209
|
...(stats[p.origin] || projectStats.NULL_STATS),
|
|
@@ -261,17 +261,21 @@ await ta('upsertProject carries the planet template, COALESCE-preserved; an unkn
|
|
|
261
261
|
for (const t of P.PROJECT_TEMPLATES.slice(0, 9)) assert.ok(mig12.includes(`'${t}'`), `migration 012 named ${t}`);
|
|
262
262
|
// and the public projection carries it, so the sky can read a curated choice
|
|
263
263
|
const pool3 = mockPool([{ rows: [] }]);
|
|
264
|
-
await P.
|
|
264
|
+
await P.listPublicProjects({ pool: pool3 });
|
|
265
265
|
const sel = (pool3.calls[0].text.match(/SELECT([\s\S]*?)FROM/i) || [, ''])[1];
|
|
266
|
-
assert.match(sel, /\btemplate\b/, 'the
|
|
266
|
+
assert.match(sel, /\btemplate\b/, 'the public projection carries the template');
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
-
await ta('
|
|
269
|
+
await ta('listPublicProjects returns a PUBLIC projection (on-the-map only, no owner/status)', async () => {
|
|
270
270
|
const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null, created_at: '2026-08-01T00:00:00Z' }] }]);
|
|
271
|
-
const out = await P.
|
|
271
|
+
const out = await P.listPublicProjects({ pool });
|
|
272
272
|
assert.equal(out.length, 1);
|
|
273
273
|
const c = pool.calls[0];
|
|
274
|
-
|
|
274
|
+
// R06 (task 1002325) replaced the `featured = true` gate with the derived publish
|
|
275
|
+
// gate: a project is on the map iff it is active AND publishable (ADR 0182 D3).
|
|
276
|
+
// The full predicate + the stealth redaction are pinned by tests/visible_projects_feed.mjs.
|
|
277
|
+
assert.match(c.text, /WHERE status = 'active' AND /);
|
|
278
|
+
assert.ok(c.text.includes(P.publishableSql()), 'the map predicate is the shared gate');
|
|
275
279
|
// Pin the SELECT LIST itself, not the whole query — an ORDER BY mention must
|
|
276
280
|
// not satisfy a projection guard, and SELECT * must never sneak owner/status
|
|
277
281
|
// onto a CORS-open rank:public route.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.515",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.515",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.515",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.515'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -182,3 +182,38 @@ test('migration objects are all governance_-namespaced (ADR 0083 §5)', () => {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
// THE GAP THE DRIFT GUARD ABOVE CANNOT SEE (found by the task 1003177 grader).
|
|
187
|
+
//
|
|
188
|
+
// The union model is a regex over `SELECT '<rank>', unnest(ARRAY[…])`. It reads
|
|
189
|
+
// the KEYS and never looks at the table those keys are inserted INTO — so a new
|
|
190
|
+
// grant migration copied from a `governance_*` sibling passes it while writing to
|
|
191
|
+
// `governance_role_permissions (role_key, …)`, the PRE-rename names.
|
|
192
|
+
//
|
|
193
|
+
// government_001_rename_from_governance renamed that table to
|
|
194
|
+
// `government_rank_permissions` and its `role_key` column to `rank_key`, with no
|
|
195
|
+
// compatibility shim. Migrations apply in filename order, so on any database —
|
|
196
|
+
// a fresh install included — a `government_*`-prefixed file writes AFTER the
|
|
197
|
+
// rename and must use the new names or error at apply time. The permission would
|
|
198
|
+
// then never resolve, and its routes would 403 for every rank, Archon included.
|
|
199
|
+
//
|
|
200
|
+
// Scoped to the `government_`-prefixed files on purpose: the historical
|
|
201
|
+
// `governance_*` migrations legitimately name the pre-rename tables (they ran
|
|
202
|
+
// before the rename, and renaming an applied migration re-runs it — the standing
|
|
203
|
+
// rule in modules/government/CLAUDE.md).
|
|
204
|
+
test('a post-rename grant migration writes to the RENAMED table, not the old one', () => {
|
|
205
|
+
const postRename = grantSqlByFile.filter(([f]) => f.startsWith('government_'));
|
|
206
|
+
assert.ok(postRename.length > 0, 'there is at least one government_-prefixed grant migration');
|
|
207
|
+
|
|
208
|
+
for (const [file, sql] of postRename) {
|
|
209
|
+
const ddl = sql.replace(/^\s*--.*$/gm, ''); // a comment may CITE the old name
|
|
210
|
+
assert.ok(!/governance_role_permissions/.test(ddl),
|
|
211
|
+
`${file}: writes to governance_role_permissions — renamed to government_rank_permissions by government_001`);
|
|
212
|
+
assert.ok(!/\brole_key\b/.test(ddl),
|
|
213
|
+
`${file}: names the role_key column — renamed to rank_key by government_001`);
|
|
214
|
+
if (/INSERT INTO/.test(ddl)) {
|
|
215
|
+
assert.match(ddl, /INSERT INTO government_rank_permissions \(rank_key, permission_key\)/,
|
|
216
|
+
`${file}: a grant insert must target government_rank_permissions (rank_key, permission_key)`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|