@bongos/core 1.19.419 → 1.19.421
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 +108 -58
- package/.claude/skills/new-project/SKILL.md +22 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +103 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +282 -269
- package/docs/copy-registry.json +487 -368
- package/docs/design/mocks/hub/mine.html +377 -0
- package/docs/design/projects-hub-direction-v2.md +25 -1
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/settings.html +3 -2
- package/modules/hall-ui/public/settings.js +10 -6
- package/modules/onboarding/routes/access-requests.js +16 -3
- package/modules/platform-identity/account-visibility.js +94 -3
- package/modules/platform-identity/platform-identity.js +50 -55
- package/modules/platform-identity/routes/connections.js +3 -3
- package/modules/platform-identity/tests/platform-identity.mjs +20 -0
- package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
- package/modules/provisioning/onboard-plan.js +13 -0
- package/modules/provisioning/provisioning.js +113 -5
- package/modules/provisioning/routes/provisioning.js +105 -0
- package/modules/provisioning/tests/provisioning.mjs +84 -0
- package/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
- package/modules/ui-design/kit/lib.js +6 -2
- package/modules/ui-design/kit/probe.js +1 -1
- package/modules/ui-design/kit/render.js +1 -1
- package/modules/ui-design/kit/serve.js +63 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +224 -18
- package/public-landing/projects-empty.states.json +49 -0
- package/public-landing/projects-floor.states.json +63 -0
- package/public-landing/projects.html +273 -93
- package/public-landing/projects.probes.json +32 -7
- package/public-landing/projects.states.json +10 -6
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/onboard.js +41 -1
- package/scripts/gds/provision-net.js +7 -3
- package/src/bongos/middleware/rate-limit.js +68 -2
- package/src/bongos/routes/auth.js +19 -12
- package/src/module-api.js +13 -1
- package/tests/account_existence_rate_limit.mjs +375 -0
- package/tests/account_privacy_flags.mjs +17 -10
- package/tests/effective_visibility_predicate.mjs +891 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboard.mjs +56 -0
- package/tests/profile_route.mjs +22 -11
- package/tests/projects_hub.mjs +102 -5
- package/tests/projects_hub_pre_uat.mjs +45 -11
- package/tests/provision.mjs +20 -0
- package/tests/ui_design_kit.mjs +32 -2
|
@@ -14,10 +14,37 @@
|
|
|
14
14
|
// already at the file-size ratchet. So the definition lands here, alone and
|
|
15
15
|
// tiny, and 1002967 ADOPTS it rather than writing a second one.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
17
|
+
// THREE AXES, THREE ANSWERS, ONE HOME (task 1002967 adopting this file). The
|
|
18
|
+
// axes stay independent — conflating them is the trap the privacy spec D1/D2
|
|
19
|
+
// exists to prevent — but every EFFECTIVE answer derived from them lives here,
|
|
20
|
+
// because an answer that lives anywhere else is a second variant waiting to
|
|
21
|
+
// drift:
|
|
22
|
+
//
|
|
23
|
+
// accountVisibleSql / isAccountVisible EXISTENCE — may the world see that
|
|
24
|
+
// this account exists? Every PUBLIC read floors on this.
|
|
25
|
+
// accountActiveSql / isAccountActive CONSENT — active, without the
|
|
26
|
+
// existence axis. The OWN-SCOPE floor (ADR 0202 D4 §2): a private account
|
|
27
|
+
// is still a real participant in its own scope.
|
|
28
|
+
// rollupVisibleSql / isRollupVisible NUMBERS — visible AND NOT
|
|
29
|
+
// hide_stats. ADR 0171 D2's predicate EXTENDED with the existence axis,
|
|
30
|
+
// and COMPOSED from accountVisibleSql rather than restated.
|
|
31
|
+
// isRecruiterDiscoverable REACH — the COALESCE, privacy
|
|
32
|
+
// spec D1. A reach predicate only: it never buys addressability.
|
|
33
|
+
//
|
|
34
|
+
// The rollup pair was originally left to "the rollup readers" to apply. Task
|
|
35
|
+
// 1002967 moved it here for the reason this file exists: there are two such
|
|
36
|
+
// readers (listScoutingRollup and getPublicProfileExtras), so leaving them to
|
|
37
|
+
// spell `AND NOT hide_stats` themselves is exactly the second-variant problem,
|
|
38
|
+
// one level down. hide_stats still suppresses NUMBERS and never existence —
|
|
39
|
+
// that is why it appears in rollupVisibleSql and in nothing above it.
|
|
40
|
+
//
|
|
19
41
|
// Not authority: an account being visible confers nothing (ADR 0016).
|
|
20
42
|
//
|
|
43
|
+
// tests/effective_visibility_predicate.mjs enforces that this file is the ONLY
|
|
44
|
+
// first-party server file allowed to compare these columns to a literal, and
|
|
45
|
+
// that every SELECT on platform_identity_accounts either joins one of these
|
|
46
|
+
// predicates or is declared non-public with a reason.
|
|
47
|
+
//
|
|
21
48
|
// Fail-closed: a missing/absent account row is invisible, never visible
|
|
22
49
|
// (ADR 0192 §6 / ADR 0194 §2).
|
|
23
50
|
'use strict';
|
|
@@ -55,4 +82,68 @@ function accountActiveSql(alias) {
|
|
|
55
82
|
return `(${alias ? `${alias}.` : ''}profile_state = 'active')`;
|
|
56
83
|
}
|
|
57
84
|
|
|
58
|
-
|
|
85
|
+
// The JS twin of accountActiveSql, for an own-scope reader that already holds
|
|
86
|
+
// the row (routes/connections.js). Fail-closed, same as isAccountVisible.
|
|
87
|
+
function isAccountActive(account) {
|
|
88
|
+
return !!account && account.profile_state === 'active';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// -------------------------------------------------------------------------
|
|
92
|
+
// The rollup axis: visible AND not hidden (task 1002967 — ADR 0171 D2 extended)
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
// The predicate the two ROLLUP readers join — listScoutingRollup (/people,
|
|
96
|
+
// /scouting) and getPublicProfileExtras (the platform-identity.hub port behind
|
|
97
|
+
// core GET /builders/:id/profile). COMPOSED from accountVisibleSql, never
|
|
98
|
+
// restated, so changing what "visible" means moves this with it.
|
|
99
|
+
function rollupVisibleSql(alias) {
|
|
100
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
101
|
+
throw new Error('rollupVisibleSql: alias must be a bare SQL identifier');
|
|
102
|
+
}
|
|
103
|
+
return `(${accountVisibleSql(alias)} AND NOT ${alias ? `${alias}.` : ''}hide_stats)`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The JS twin of rollupVisibleSql, for a reader that already holds the row.
|
|
107
|
+
function isRollupVisible(account) {
|
|
108
|
+
return isAccountVisible(account) && !account.hide_stats;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
// The reach axis: recruiting discoverability (privacy spec D1)
|
|
113
|
+
// -------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
// The two stored values of account_visibility. Mirrors the CHECK in
|
|
116
|
+
// migrations/platform_identity_012_account_privacy.sql.
|
|
117
|
+
const ACCOUNT_VISIBILITY = ['public', 'private'];
|
|
118
|
+
|
|
119
|
+
// isRecruiterDiscoverable(account) — THE effective-recruiter-reach predicate.
|
|
120
|
+
// The JS twin of COALESCE(recruiter_discoverable, account_visibility='public');
|
|
121
|
+
// nothing else may spell that COALESCE out. Moved here from platform-identity.js
|
|
122
|
+
// by task 1002967 so all three effective answers share one home.
|
|
123
|
+
//
|
|
124
|
+
// The tri-state is the point: NULL = follow visibility (opt-OUT for a public
|
|
125
|
+
// account, opt-IN for a private one), an explicit boolean overrides — an
|
|
126
|
+
// explicit true on a PRIVATE account is exactly the privacy spec's D2
|
|
127
|
+
// "private, discoverable" row, which the D3 recruiter sliver exists to serve.
|
|
128
|
+
//
|
|
129
|
+
// NOT a complete gate, and NEVER addressability: profile_state='active' still
|
|
130
|
+
// floors every recruiter surface, hide_stats still empties the sliver to
|
|
131
|
+
// name-only, and a discoverable private account is still absent from every
|
|
132
|
+
// public read. Fail-closed on a missing account.
|
|
133
|
+
function isRecruiterDiscoverable(account) {
|
|
134
|
+
if (!account) return false;
|
|
135
|
+
const explicit = account.recruiter_discoverable;
|
|
136
|
+
if (typeof explicit === 'boolean') return explicit;
|
|
137
|
+
return account.account_visibility === 'public';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
isAccountVisible,
|
|
142
|
+
accountVisibleSql,
|
|
143
|
+
accountActiveSql,
|
|
144
|
+
isAccountActive,
|
|
145
|
+
rollupVisibleSql,
|
|
146
|
+
isRollupVisible,
|
|
147
|
+
ACCOUNT_VISIBILITY,
|
|
148
|
+
isRecruiterDiscoverable,
|
|
149
|
+
};
|
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
const crypto = require('node:crypto');
|
|
16
16
|
const api = require('../../src/module-api');
|
|
17
|
+
// THE effective-visibility predicate (task 1002967, adopting the file ADR 0202
|
|
18
|
+
// D4 named). Every public read below joins it from here; nothing in this file
|
|
19
|
+
// re-derives one (see account-visibility.js).
|
|
20
|
+
const visibility = require('./account-visibility');
|
|
17
21
|
|
|
18
22
|
const { pool: defaultPool } = api;
|
|
23
|
+
const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible } = visibility;
|
|
19
24
|
|
|
20
25
|
// Default authorization-code lifetime — deliberately short (~60s). A code is a
|
|
21
26
|
// bearer secret that travels through the browser; a tight TTL bounds the replay
|
|
@@ -201,7 +206,7 @@ async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
|
201
206
|
// (task #1002109) calls this with a freshly generated secret; the plaintext is
|
|
202
207
|
// hashed here and never stored. Upsert-on-client_id so re-provisioning rotates
|
|
203
208
|
// the secret + redirect_uris without a duplicate row.
|
|
204
|
-
async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId, type }, { pool = defaultPool } = {}) {
|
|
209
|
+
async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId, type, description }, { pool = defaultPool } = {}) {
|
|
205
210
|
const secretHash = sha256hex(secret);
|
|
206
211
|
const uris = Array.isArray(redirectUris) ? redirectUris : [];
|
|
207
212
|
const { rows } = await pool.query(
|
|
@@ -218,11 +223,11 @@ async function registerClient({ clientId, secret, name, redirectUris, origin, ow
|
|
|
218
223
|
RETURNING id, client_id, name, redirect_uris, origin, owner_github_id, status`,
|
|
219
224
|
[clientId, secretHash, name, uris, origin ?? null, ownerGithubId ?? null],
|
|
220
225
|
);
|
|
221
|
-
// Auto-catalogue the project (unfeatured) so it appears in the Archon curation
|
|
222
|
-
//
|
|
223
|
-
//
|
|
226
|
+
// Auto-catalogue the project (unfeatured) so it appears in the Archon curation list the
|
|
227
|
+
// moment it federates (ADR 0141 §5). Best-effort — a hiccup must never fail registration;
|
|
228
|
+
// only when an origin is known. `description` rides along like `type` (ADR 0182 D4, task 1002334).
|
|
224
229
|
if (origin) {
|
|
225
|
-
try { await upsertProject({ origin, name, ownerGithubId, type }, { pool }); }
|
|
230
|
+
try { await upsertProject({ origin, name, description, ownerGithubId, type }, { pool }); }
|
|
226
231
|
catch (err) { console.error('[platform-identity] auto-catalogue failed (non-fatal):', err.message); }
|
|
227
232
|
}
|
|
228
233
|
return rows[0];
|
|
@@ -717,11 +722,15 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
717
722
|
// disciplines union is computed separately in `disc`. Proven against real SQL by
|
|
718
723
|
// tests/scouting_rollup_fanout.mjs.
|
|
719
724
|
//
|
|
720
|
-
// Consent by construction (ADR 0171 D2/D3
|
|
721
|
-
//
|
|
722
|
-
// live cross-project surfaces, so the
|
|
723
|
-
// caller's memory
|
|
724
|
-
//
|
|
725
|
+
// Consent by construction (ADR 0171 D2/D3, extended by task 1002967): only
|
|
726
|
+
// builders whose account passes rollupVisibleSql — active, publicly visible, not
|
|
727
|
+
// hidden — appear. /people and /scouting are live cross-project surfaces, so the
|
|
728
|
+
// predicate lives IN the query, not in any caller's memory, and it is the SHARED
|
|
729
|
+
// one so this list can never disagree with /profiles/:handle about who is
|
|
730
|
+
// visible. Provisional, private and hidden builders drop out entirely; the
|
|
731
|
+
// projection stays an explicit whitelist (no raw-row spread). A private account
|
|
732
|
+
// that is recruiter-discoverable is absent here on purpose: the full rollup is
|
|
733
|
+
// never its disclosure — the D3 sliver is (a separate authorization-scoped port).
|
|
725
734
|
async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
726
735
|
const { rows } = await pool.query(
|
|
727
736
|
`WITH agg AS (
|
|
@@ -735,8 +744,7 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
|
735
744
|
WHERE EXISTS (
|
|
736
745
|
SELECT 1 FROM platform_identity_accounts pa
|
|
737
746
|
WHERE pa.github_id = a.github_id
|
|
738
|
-
AND pa
|
|
739
|
-
AND NOT pa.hide_stats)
|
|
747
|
+
AND ${rollupVisibleSql('pa')})
|
|
740
748
|
GROUP BY a.github_id
|
|
741
749
|
), disc AS (
|
|
742
750
|
SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
|
|
@@ -840,20 +848,21 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
840
848
|
};
|
|
841
849
|
}
|
|
842
850
|
|
|
843
|
-
// The PUBLIC read behind the 'platform-identity.hub' port (ADR 0171 D1/D2
|
|
844
|
-
// null unless the account
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
851
|
+
// The PUBLIC read behind the 'platform-identity.hub' port (ADR 0171 D1/D2,
|
|
852
|
+
// extended by task 1002967): null unless the account passes the SHARED rollup
|
|
853
|
+
// predicate — exists, consented (profile_state='active'), publicly visible
|
|
854
|
+
// (account_visibility='public') and not hidden (hide_stats). The fail-closed
|
|
855
|
+
// rule every public surface inherits by construction. A null is deliberately
|
|
856
|
+
// indistinguishable between "no account", "provisional", "private" and "hidden"
|
|
857
|
+
// (no partial numbers; a count plus a hidden total is a leak by subtraction).
|
|
849
858
|
async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
|
|
850
859
|
if (!isNumericGithubId(githubId)) return null;
|
|
851
860
|
const { rows } = await pool.query(
|
|
852
|
-
`SELECT profile_state, hide_stats
|
|
861
|
+
`SELECT profile_state, account_visibility, hide_stats
|
|
862
|
+
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
853
863
|
[githubId],
|
|
854
864
|
);
|
|
855
|
-
|
|
856
|
-
if (!acct || acct.profile_state !== 'active' || acct.hide_stats) return null;
|
|
865
|
+
if (!isRollupVisible(rows[0])) return null;
|
|
857
866
|
return getRollupForBuilder(githubId, { pool });
|
|
858
867
|
}
|
|
859
868
|
|
|
@@ -920,32 +929,10 @@ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarU
|
|
|
920
929
|
// Account privacy: visibility + recruiter reach (task 1002966 — privacy spec D1)
|
|
921
930
|
// -------------------------------------------------------------------------
|
|
922
931
|
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
// isRecruiterDiscoverable(account) — THE effective-recruiter-reach predicate
|
|
929
|
-
// (privacy spec D1). One definition, so every reader answers "may a recruiting
|
|
930
|
-
// surface see this account at all?" the same way; task 1002967 adopts this
|
|
931
|
-
// rather than re-deriving it, and nothing else should spell the COALESCE out.
|
|
932
|
-
//
|
|
933
|
-
// The JS twin of COALESCE(recruiter_discoverable, account_visibility='public').
|
|
934
|
-
// The tri-state is the point: NULL = follow visibility (opt-OUT for a public
|
|
935
|
-
// account, opt-IN for a private one), an explicit boolean overrides — an
|
|
936
|
-
// explicit true on a PRIVATE account is exactly the spec's D2 "private,
|
|
937
|
-
// discoverable" row, which is what the recruiter sliver exists to serve.
|
|
938
|
-
//
|
|
939
|
-
// NOT a complete gate on its own. profile_state='active' still floors every
|
|
940
|
-
// recruiter surface (a provisional account is never visible), and hide_stats
|
|
941
|
-
// still empties the sliver down to name-only — both stay the caller's to apply.
|
|
942
|
-
// Fail-closed on a missing/absent account.
|
|
943
|
-
function isRecruiterDiscoverable(account) {
|
|
944
|
-
if (!account) return false;
|
|
945
|
-
const explicit = account.recruiter_discoverable;
|
|
946
|
-
if (typeof explicit === 'boolean') return explicit;
|
|
947
|
-
return account.account_visibility === 'public';
|
|
948
|
-
}
|
|
932
|
+
// ACCOUNT_VISIBILITY and isRecruiterDiscoverable moved to ./account-visibility.js
|
|
933
|
+
// (task 1002967) — every effective-visibility answer now has ONE home, beside
|
|
934
|
+
// the predicate the public reads join. They are re-exported below, so `pi.`
|
|
935
|
+
// callers are unchanged.
|
|
949
936
|
|
|
950
937
|
// The own-scoped privacy WRITE (task 1002966) — callers key this by the
|
|
951
938
|
// AUTHENTICATED session's github_id, never a caller-named one. Validates here,
|
|
@@ -1123,10 +1110,18 @@ async function setAccountHandle({ githubId, handle }, { pool = defaultPool } = {
|
|
|
1123
1110
|
// resolution). Case-insensitive. An exact current-handle match always wins;
|
|
1124
1111
|
// otherwise the single-slot previous_handle matches and answers a move to the
|
|
1125
1112
|
// current handle (the 301 window from P01). Only publicly addressable accounts
|
|
1126
|
-
// resolve
|
|
1127
|
-
// the same
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1113
|
+
// resolve — the SHARED accountVisibleSql predicate (task 1002967), so this is
|
|
1114
|
+
// the same "visible" every other public read uses. Provisional, PRIVATE and
|
|
1115
|
+
// unknown are all the same null: the 404 the privacy spec D2 requires, and it is
|
|
1116
|
+
// a 404 to EVERYONE INCLUDING THE OWNER, because this function takes no viewer
|
|
1117
|
+
// and therefore cannot vary by one (public responses stay viewer-independent,
|
|
1118
|
+
// ADR 0171 D4). An owner reaches their own private page through the own-scope
|
|
1119
|
+
// side door instead (GET /me/cross-project → getOwnProfileExtras) — the page
|
|
1120
|
+
// composition varies, the public response never does. A 403 is deliberately
|
|
1121
|
+
// never returned: it would confirm the handle exists.
|
|
1122
|
+
// hide_stats deliberately does NOT gate resolution: hiding removes the rollup,
|
|
1123
|
+
// not the page (D2 — addressability and hide are different invariants), which is
|
|
1124
|
+
// exactly why accountVisibleSql and rollupVisibleSql are two predicates.
|
|
1130
1125
|
// Returns null | { moved_to } | { account: { github_id, github_login,
|
|
1131
1126
|
// display_name, avatar_url, handle, created_at } }.
|
|
1132
1127
|
async function resolveHandle(handle, { pool = defaultPool } = {}) {
|
|
@@ -1136,7 +1131,7 @@ async function resolveHandle(handle, { pool = defaultPool } = {}) {
|
|
|
1136
1131
|
`SELECT github_id, github_login, display_name, avatar_url, handle, bio, links, created_at,
|
|
1137
1132
|
(lower(handle) = $1) AS is_current
|
|
1138
1133
|
FROM platform_identity_accounts
|
|
1139
|
-
WHERE
|
|
1134
|
+
WHERE ${accountVisibleSql()}
|
|
1140
1135
|
AND (lower(handle) = $1 OR lower(previous_handle) = $1)
|
|
1141
1136
|
ORDER BY (lower(handle) = $1) DESC
|
|
1142
1137
|
LIMIT 1`,
|
|
@@ -1467,9 +1462,9 @@ module.exports = {
|
|
|
1467
1462
|
getPublicProfileExtras,
|
|
1468
1463
|
getOwnProfileExtras,
|
|
1469
1464
|
setAccountHideStats,
|
|
1470
|
-
// account privacy: visibility
|
|
1471
|
-
|
|
1472
|
-
|
|
1465
|
+
// account privacy: the effective-visibility predicates (./account-visibility.js
|
|
1466
|
+
// — one home for every effective answer; task 1002967 / ADR 0202 D4, 0207)
|
|
1467
|
+
...visibility,
|
|
1473
1468
|
setAccountPrivacy,
|
|
1474
1469
|
// builder handle (P01)
|
|
1475
1470
|
RESERVED_HANDLES,
|
|
@@ -40,7 +40,7 @@ const express = require('express');
|
|
|
40
40
|
const api = require('../../../src/module-api');
|
|
41
41
|
const pi = require('../platform-identity');
|
|
42
42
|
const conn = require('../connections');
|
|
43
|
-
const { isAccountVisible } = require('../account-visibility');
|
|
43
|
+
const { isAccountVisible, isAccountActive } = require('../account-visibility');
|
|
44
44
|
|
|
45
45
|
const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
|
|
46
46
|
const log = api.logger('platform-identity');
|
|
@@ -64,7 +64,7 @@ async function actingAccount(builder) {
|
|
|
64
64
|
return { fail: ['no_platform_identity', { status: 400, message: 'this session has no platform account' }] };
|
|
65
65
|
}
|
|
66
66
|
const acct = await pi.getAccount(githubId, { pool });
|
|
67
|
-
if (!acct
|
|
67
|
+
if (!isAccountActive(acct)) {
|
|
68
68
|
return { fail: ['profile_provisional', { status: 403, message: 'sign in to the platform once to activate your profile before connecting' }] };
|
|
69
69
|
}
|
|
70
70
|
return { githubId };
|
|
@@ -78,7 +78,7 @@ async function visibleTarget(viewerGithubId, handle) {
|
|
|
78
78
|
const acct = await conn.findAccountByHandle(handle, { pool });
|
|
79
79
|
if (!acct) return null;
|
|
80
80
|
if (isAccountVisible(acct)) return acct;
|
|
81
|
-
if (acct
|
|
81
|
+
if (!isAccountActive(acct)) return null;
|
|
82
82
|
return (await conn.isConnectedTo(viewerGithubId, acct.github_id, { pool })) ? acct : null;
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -406,6 +406,26 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
|
|
|
406
406
|
assert.match(pool.calls[1].text, /INSERT INTO platform_identity_projects/);
|
|
407
407
|
});
|
|
408
408
|
|
|
409
|
+
await ta('registerClient forwards the project-detail description into the catalog row (task 1002334)', async () => {
|
|
410
|
+
// Half the publish gate (ADR 0182 D4). It is given on the platform before this
|
|
411
|
+
// row exists, so if this hop drops it a project that DID describe itself sits
|
|
412
|
+
// in dark matter and the optional step is decorative.
|
|
413
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
|
|
414
|
+
await P.registerClient(
|
|
415
|
+
{ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p', ownerGithubId: 7, description: 'A weather sim.' },
|
|
416
|
+
{ pool },
|
|
417
|
+
);
|
|
418
|
+
assert.ok(pool.calls[1].params.includes('A weather sim.'), 'the description reaches the catalog upsert');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await ta('registerClient with a SKIPPED interview passes no description — and COALESCE nulls nothing', async () => {
|
|
422
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
|
|
423
|
+
await P.registerClient({ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p' }, { pool });
|
|
424
|
+
assert.equal(pool.calls[1].params[3], null, 'absent travels as null');
|
|
425
|
+
assert.match(pool.calls[1].text, /description = COALESCE\(EXCLUDED\.description/,
|
|
426
|
+
'so a re-registration can never erase a description the owner added later');
|
|
427
|
+
});
|
|
428
|
+
|
|
409
429
|
console.log('\nproject memberships (§3 check-in directory):');
|
|
410
430
|
|
|
411
431
|
await ta('recordFederatedSignin is the ONE path that creates a membership, and stamps the witness', async () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- provisioning_013_project_detail.sql — the optional project-detail interview's
|
|
2
|
+
-- storage (BV1.R15, task 1002334; goal 1000046 criterion `proj-detail-interview`).
|
|
3
|
+
--
|
|
4
|
+
-- The creation sequence gains ONE optional, skippable step — "tell us about your
|
|
5
|
+
-- project" — and its answers have to live somewhere real the moment they are
|
|
6
|
+
-- given. A standalone project never federates into the hub catalog at create
|
|
7
|
+
-- time, so this mirrors exactly what provisioning_007 did for the declared
|
|
8
|
+
-- `type`: the answer rides the provisioning request, and the control-plane
|
|
9
|
+
-- runner carries it into platform_identity_projects whenever a catalog row
|
|
10
|
+
-- materializes.
|
|
11
|
+
--
|
|
12
|
+
-- TWO columns, deliberately not one:
|
|
13
|
+
--
|
|
14
|
+
-- description a REAL column, because it is half of the publish gate (ADR 0182
|
|
15
|
+
-- D4: publishable iff name AND description are both present). It
|
|
16
|
+
-- mirrors platform_identity_projects.description, which
|
|
17
|
+
-- platform_identity_011 added for the same reason. NOT `tagline` —
|
|
18
|
+
-- ADR 0182 refuses to collapse them, since a description is the
|
|
19
|
+
-- publish bar and editing your card must never unpublish you.
|
|
20
|
+
--
|
|
21
|
+
-- detail jsonb, because the interview grows one answer at a time as R17
|
|
22
|
+
-- (rule-based recommendations), R19 (starter bundles) and R27
|
|
23
|
+
-- (invites) land, and a new question must not cost a migration
|
|
24
|
+
-- each time. Same contract as `settings` (provisioning_009): the
|
|
25
|
+
-- column is a dumb store, the known keys + their vocabularies are
|
|
26
|
+
-- enforced in code (provisioning.PROJECT_DETAIL_VOCAB +
|
|
27
|
+
-- normalizeProjectDetail), and an unknown or off-vocabulary stored
|
|
28
|
+
-- value is dropped at read time rather than leaking out.
|
|
29
|
+
--
|
|
30
|
+
-- SKIPPING IS THE DEFAULT, not a failure: both columns are nullable/empty, and an
|
|
31
|
+
-- empty description is precisely what leaves a project dark-matter until its
|
|
32
|
+
-- owner fills it in (ADR 0182 D4 — the bar exists to keep empty husks off the
|
|
33
|
+
-- map, not to enforce completeness). Nothing here changes live behaviour; R16
|
|
34
|
+
-- (task 1002335) is the surface that computes publishability and names what is
|
|
35
|
+
-- missing.
|
|
36
|
+
--
|
|
37
|
+
-- Additive + provisioning_-namespaced (ADR 0083 §5): ADD COLUMN only, so the
|
|
38
|
+
-- previous code release still runs against this schema. Idempotent: safe to re-run.
|
|
39
|
+
|
|
40
|
+
BEGIN;
|
|
41
|
+
|
|
42
|
+
ALTER TABLE provisioning_instances
|
|
43
|
+
ADD COLUMN IF NOT EXISTS description text;
|
|
44
|
+
|
|
45
|
+
ALTER TABLE provisioning_instances
|
|
46
|
+
ADD COLUMN IF NOT EXISTS detail jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
47
|
+
|
|
48
|
+
COMMENT ON COLUMN provisioning_instances.description IS
|
|
49
|
+
'What the owner said they are building — the answer to the optional project-detail step (BV1.R15). Half of the publish gate (ADR 0182 D4: publishable iff name AND description are present), mirrored into platform_identity_projects.description when a catalog row materializes. NULL = the step was skipped, which is a supported default, not a failure: the project stays dark-matter until it is filled in.';
|
|
50
|
+
|
|
51
|
+
COMMENT ON COLUMN provisioning_instances.detail IS
|
|
52
|
+
'The rest of the optional project-detail interview, as a dumb jsonb store (the `settings` contract from provisioning_009). Known keys + vocabularies live in provisioning.PROJECT_DETAIL_VOCAB and are enforced at the API layer; an unknown or off-vocabulary stored value is dropped at read time. jsonb so the interview can grow an answer at a time (R17 recommendations, R19 bundles, R27 invites) without a migration each time. {} = skipped.';
|
|
53
|
+
|
|
54
|
+
COMMIT;
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
//
|
|
10
10
|
// PURE — no I/O, no core imports. Safe to require from a script, a route, or a test.
|
|
11
11
|
//
|
|
12
|
+
// `optional: true` (task 1002334) marks a step every form must OFFER and every
|
|
13
|
+
// owner may SKIP — today just `project-detail`, the "tell us about your project"
|
|
14
|
+
// interview. It is a real step in the sequence rather than a wizard-only flourish
|
|
15
|
+
// because all four forms have to ask it the same way; skipping is a supported
|
|
16
|
+
// default that leaves the project dark-matter (off the map, un-joinable) until its
|
|
17
|
+
// description is filled in later, never a dead end (ADR 0182 D4). A form that can
|
|
18
|
+
// only render a linear checklist may treat an optional step as any other; one that
|
|
19
|
+
// takes answers must let the owner past it without one.
|
|
20
|
+
//
|
|
12
21
|
// Each step: { key, kind: 'auto' | 'human', label }. `kind: 'human'` marks the
|
|
13
22
|
// irreducibly-manual owner actions (create the GitHub repo, wire GitHub sign-in, first
|
|
14
23
|
// sign-in, enter the hall) that every form renders UI-first; `kind: 'auto'` steps the
|
|
@@ -36,6 +45,10 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
|
|
|
36
45
|
const domain = typeof opts.domain === 'string' && opts.domain.trim() ? opts.domain.trim() : null;
|
|
37
46
|
return [
|
|
38
47
|
{ key: 'name-address', kind: 'auto', label: 'Decide name + address (interview)' },
|
|
48
|
+
// 'auto' like name-address above, and for the same reason: the form itself
|
|
49
|
+
// collects it, so it is not one of the irreducibly-manual legs a done panel
|
|
50
|
+
// lists. `optional` is what makes it skippable — see the header.
|
|
51
|
+
{ key: 'project-detail', kind: 'auto', optional: true, label: 'Tell us about your project — optional, skip it and add it later' },
|
|
39
52
|
{ key: 'github-repo', kind: 'human', label: adopt ? 'Use your EXISTING GitHub repository' : 'Create the empty GitHub repository' },
|
|
40
53
|
{ key: 'dns-preflight', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'DNS token preflight — before the address step (task 1980)' },
|
|
41
54
|
{ key: 'init', kind: 'auto', label: adopt ? 'bongos init --adopt — LAYER onto the existing repo (pre-flight + accept)' : 'bongos init — scaffold the instance config' },
|
|
@@ -124,6 +124,34 @@ const SETTINGS_VOCAB = Object.freeze({
|
|
|
124
124
|
});
|
|
125
125
|
const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply' });
|
|
126
126
|
|
|
127
|
+
// The optional project-detail interview (BV1.R15, task 1002334) — the "tell us
|
|
128
|
+
// about your project" step every creation form offers and every owner may skip.
|
|
129
|
+
// The interview asks two things, and the vocabulary is the contract for the
|
|
130
|
+
// closed-answer half:
|
|
131
|
+
//
|
|
132
|
+
// team_shape who is going to build it — the input R17 (task 1002336) maps to a
|
|
133
|
+
// recommended governance preset and to invite suggestions.
|
|
134
|
+
//
|
|
135
|
+
// (The module bundle R19 recommends comes off the separately-declared `type`, so
|
|
136
|
+
// the interview deliberately does not ask it a second time.)
|
|
137
|
+
//
|
|
138
|
+
// The free-text half of the interview is NOT here: `description` is a real column
|
|
139
|
+
// (migration provisioning_013) because it is half of the publish gate (ADR 0182
|
|
140
|
+
// D4), while these are jsonb so a later question costs no migration — same dumb-
|
|
141
|
+
// store contract as SETTINGS_VOCAB above.
|
|
142
|
+
//
|
|
143
|
+
// There is deliberately NO default. Settings describe a policy the instance must
|
|
144
|
+
// obey, so an absent one has to resolve to today's behaviour; a detail answer is
|
|
145
|
+
// something the owner SAID, and inventing one they never said would feed R17 a
|
|
146
|
+
// recommendation nobody asked for. Absent stays absent, and skipped stays skipped.
|
|
147
|
+
const PROJECT_DETAIL_VOCAB = Object.freeze({
|
|
148
|
+
team_shape: ['solo', 'small-team', 'community'],
|
|
149
|
+
});
|
|
150
|
+
// The publish-gate half of the interview is prose, so it needs a ceiling. Long
|
|
151
|
+
// enough for a real paragraph, short enough that the column is never a document —
|
|
152
|
+
// the Explore card renders `tagline`, not this.
|
|
153
|
+
const DESCRIPTION_MAX = 600;
|
|
154
|
+
|
|
127
155
|
// Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
|
|
128
156
|
// (ADR 0111 §6), never a bill. A small local table so this module needs no
|
|
129
157
|
// sibling-module import; P2 (shared control-plane client extraction) is where a
|
|
@@ -257,6 +285,14 @@ function publicInstance(row) {
|
|
|
257
285
|
// instances so a declaration shows before the catalog carries it. Nullable:
|
|
258
286
|
// rows predating provisioning_007, or a declined answer, project null.
|
|
259
287
|
type: row.type || null,
|
|
288
|
+
// The optional project-detail interview (task 1002334): what the owner said
|
|
289
|
+
// they are building, and the answers that feed R17's recommendations. Both
|
|
290
|
+
// are legitimately empty — the step is skippable by design — and `description`
|
|
291
|
+
// being null is exactly what keeps the project dark-matter until it is filled
|
|
292
|
+
// in (ADR 0182 D4). Surfaced so a form can render "here's what's still
|
|
293
|
+
// missing" from a plain instance read, without a second endpoint.
|
|
294
|
+
description: row.description || null,
|
|
295
|
+
detail: projectDetail(row),
|
|
260
296
|
// The verification overlay (task 1002696, redesign direction §3): what the
|
|
261
297
|
// heartbeat last OBSERVED, which is NOT what `status` claims. null = never
|
|
262
298
|
// probed — a different claim from last_verified_up=false, and the UI renders
|
|
@@ -330,6 +366,40 @@ function effectiveSettings(row) {
|
|
|
330
366
|
return out;
|
|
331
367
|
}
|
|
332
368
|
|
|
369
|
+
// A description as it may be STORED: trimmed, collapsed of runaway whitespace,
|
|
370
|
+
// capped, and empty-means-absent. Returns null for anything that isn't real prose,
|
|
371
|
+
// because null is what leaves the project dark-matter — an empty string stored in a
|
|
372
|
+
// nullable column would look answered to `description IS NOT NULL` and publish an
|
|
373
|
+
// husk, which is the one thing the gate exists to prevent (ADR 0182 D4). PURE.
|
|
374
|
+
function normalizeDescription(input) {
|
|
375
|
+
if (typeof input !== 'string') return null;
|
|
376
|
+
const text = input.replace(/\s+/g, ' ').trim();
|
|
377
|
+
if (!text) return null;
|
|
378
|
+
return text.slice(0, DESCRIPTION_MAX);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The detail answers as they may be STORED: on-vocabulary keys only, everything
|
|
382
|
+
// else dropped. An off-vocabulary value is dropped rather than defaulted — a
|
|
383
|
+
// recommendation built on an answer the owner never gave is worse than no
|
|
384
|
+
// recommendation (see PROJECT_DETAIL_VOCAB). Always returns a plain object, so a
|
|
385
|
+
// skipped interview stores `{}` rather than null. PURE.
|
|
386
|
+
function normalizeProjectDetail(input) {
|
|
387
|
+
const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
388
|
+
const out = {};
|
|
389
|
+
for (const [key, vocab] of Object.entries(PROJECT_DETAIL_VOCAB)) {
|
|
390
|
+
if (vocab.includes(given[key])) out[key] = given[key];
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// What a row's detail answers READ as — the stored object filtered through the
|
|
396
|
+
// same vocabulary, so a value that was valid when written and has since left the
|
|
397
|
+
// vocabulary never leaks out of the read surface. Mirrors effectiveSettings, minus
|
|
398
|
+
// the defaults it deliberately has none of. PURE.
|
|
399
|
+
function projectDetail(row) {
|
|
400
|
+
return normalizeProjectDetail(row && row.detail);
|
|
401
|
+
}
|
|
402
|
+
|
|
333
403
|
// ---------------------------------------------------------------------------
|
|
334
404
|
// GitHub App Manifest flow — PURE builders (task 2080, ADR 0133).
|
|
335
405
|
// GitHub has no create-an-OAuth-App API, but the App *Manifest* flow lets the owner
|
|
@@ -479,6 +549,35 @@ async function updateInstanceSettings(db, id, patch) {
|
|
|
479
549
|
return rows[0] || null;
|
|
480
550
|
}
|
|
481
551
|
|
|
552
|
+
// Fill in (or change) the project-detail answers AFTER creation — the other half
|
|
553
|
+
// of "skippable" (task 1002334). Skipping the step at creation is a supported
|
|
554
|
+
// default, but it is only a default and not a dead end if there is a way back; this
|
|
555
|
+
// is that way back, and it is what turns a dark-matter project publishable once its
|
|
556
|
+
// owner writes a description (ADR 0182 D4).
|
|
557
|
+
//
|
|
558
|
+
// `detail` MERGES (the settings `||` precedent — a form may send one answer without
|
|
559
|
+
// resending the rest) while `description` REPLACES, because prose has no keys to
|
|
560
|
+
// merge. Passing description: null clears it — and un-publishes the project, which
|
|
561
|
+
// is the honest consequence of deleting half the identity gate, not a bug.
|
|
562
|
+
// Undefined leaves the column alone.
|
|
563
|
+
async function updateInstanceDetail(db, id, { description, detail } = {}) {
|
|
564
|
+
const sets = ['updated_at = now()'];
|
|
565
|
+
const params = [id];
|
|
566
|
+
if (description !== undefined) {
|
|
567
|
+
params.push(description);
|
|
568
|
+
sets.push(`description = $${params.length}`);
|
|
569
|
+
}
|
|
570
|
+
if (detail !== undefined) {
|
|
571
|
+
params.push(JSON.stringify(normalizeProjectDetail(detail)));
|
|
572
|
+
sets.push(`detail = detail || $${params.length}::jsonb`);
|
|
573
|
+
}
|
|
574
|
+
const { rows } = await db.query(
|
|
575
|
+
`UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
|
|
576
|
+
params
|
|
577
|
+
);
|
|
578
|
+
return rows[0] || null;
|
|
579
|
+
}
|
|
580
|
+
|
|
482
581
|
// Record an instance's domain — the ONLY columns this touches (task 1002692;
|
|
483
582
|
// `releasing` added by task 1003237). Status changes stay setInstanceStatus's job;
|
|
484
583
|
// keeping this narrow means the domain-attach route can never drift an instance's
|
|
@@ -630,7 +729,7 @@ async function listAllInstances(db, { status } = {}) {
|
|
|
630
729
|
// re-fetches the existing row and returns { created:false } so the ROUTE can
|
|
631
730
|
// decide (same owner → idempotent re-request; different owner → 409). Never
|
|
632
731
|
// silently reassigns ownership.
|
|
633
|
-
async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type }) {
|
|
732
|
+
async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail }) {
|
|
634
733
|
const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
|
|
635
734
|
// Unknown/absent → 'greenfield', matching the column default. Falling back to the
|
|
636
735
|
// SAFE mode matters: greenfield refuses to push over existing history (no --force
|
|
@@ -639,15 +738,22 @@ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShap
|
|
|
639
738
|
const mode = ONBOARD_MODES.includes(onboardMode) ? onboardMode : 'greenfield';
|
|
640
739
|
// Type is a declaration — off-vocabulary or absent stores null, never a guess.
|
|
641
740
|
const declaredType = PROJECT_TYPES.includes(type) ? type : null;
|
|
741
|
+
// The optional project-detail step (task 1002334). Skipping it is the SUPPORTED
|
|
742
|
+
// default, so both of these legitimately land empty — a null description is what
|
|
743
|
+
// leaves the project dark-matter until its owner fills it in (ADR 0182 D4), and
|
|
744
|
+
// updateInstanceDetail is the way back.
|
|
745
|
+
const told = normalizeDescription(description);
|
|
746
|
+
const answers = normalizeProjectDetail(detail);
|
|
642
747
|
const cost = costEstimateUsd({ hostingShape: shape, tier });
|
|
643
748
|
try {
|
|
644
749
|
const { rows } = await db.query(
|
|
645
750
|
`INSERT INTO provisioning_instances
|
|
646
|
-
(owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type)
|
|
647
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
751
|
+
(owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type, description, detail)
|
|
752
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
|
|
648
753
|
RETURNING *`,
|
|
649
754
|
[ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
|
|
650
|
-
dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType
|
|
755
|
+
dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType,
|
|
756
|
+
told, JSON.stringify(answers)]
|
|
651
757
|
);
|
|
652
758
|
return { instance: rows[0], created: true };
|
|
653
759
|
} catch (e) {
|
|
@@ -959,10 +1065,11 @@ module.exports = {
|
|
|
959
1065
|
SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
|
|
960
1066
|
RESTART_FROM, RESTART_SHAPES,
|
|
961
1067
|
TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
|
|
962
|
-
SETTINGS_VOCAB, DEFAULT_SETTINGS,
|
|
1068
|
+
SETTINGS_VOCAB, DEFAULT_SETTINGS, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
|
|
963
1069
|
// pure
|
|
964
1070
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
965
1071
|
defaultDomainForSlug, isReservedHostname, effectiveSettings,
|
|
1072
|
+
normalizeDescription, normalizeProjectDetail, projectDetail,
|
|
966
1073
|
// pure — GitHub App Manifest flow (task 2080)
|
|
967
1074
|
githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
|
|
968
1075
|
// data access
|
|
@@ -970,6 +1077,7 @@ module.exports = {
|
|
|
970
1077
|
getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
|
|
971
1078
|
// data access — verification overlay (task 1002696)
|
|
972
1079
|
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
1080
|
+
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
|
|
973
1081
|
createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
|
|
974
1082
|
enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
|
|
975
1083
|
recordEvent, instanceStateForSlug, costLedger,
|