@reventlessdev/reventless-local 3.0.0-alpha.220 → 3.0.0-alpha.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,172 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodepath from "node:path";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Seed_Prompt$ReventlessSeed from "@reventlessdev/reventless-seed/src/Seed_Prompt.res.mjs";
6
+ import * as Seed_Connect$ReventlessSeed from "@reventlessdev/reventless-seed/src/Seed_Connect.res.mjs";
7
+ import * as LocalPlatformRegistry$ReventlessLocal from "./LocalPlatformRegistry.res.mjs";
8
+
9
+ let defaultEndpoint = "http://localhost:4000/graphql";
10
+
11
+ function loginFor(endpoint) {
12
+ let i = endpoint.indexOf("://");
13
+ if (i === -1) {
14
+ return endpoint;
15
+ }
16
+ let afterScheme = i + 3 | 0;
17
+ let slash = endpoint.indexOf("/", afterScheme);
18
+ return (
19
+ slash !== -1 ? endpoint.slice(0, slash) : endpoint
20
+ ) + "/__inmemory/login";
21
+ }
22
+
23
+ function relativeIfInside(path) {
24
+ let cwd = process.cwd();
25
+ if (path.startsWith(cwd + Nodepath.sep)) {
26
+ return path.slice((cwd + Nodepath.sep).length);
27
+ } else {
28
+ return path;
29
+ }
30
+ }
31
+
32
+ function storeLabel(store) {
33
+ let match = store.kind;
34
+ let match$1 = store.path;
35
+ if (match === "sqlite" && match$1 !== undefined) {
36
+ return `sqlite ` + relativeIfInside(match$1);
37
+ } else {
38
+ return match;
39
+ }
40
+ }
41
+
42
+ function shortAppName(app) {
43
+ let i = app.indexOf("/");
44
+ if (i !== -1) {
45
+ return app.slice(i + 1 | 0);
46
+ } else {
47
+ return app;
48
+ }
49
+ }
50
+
51
+ function entryLabel(e) {
52
+ return `:` + e.port.toString() + ` ` + shortAppName(e.app) + ` ` + storeLabel(e.store);
53
+ }
54
+
55
+ async function select() {
56
+ let endpoint = Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_GRAPHQL_ENDPOINT");
57
+ if (endpoint !== undefined) {
58
+ return {
59
+ endpoint: endpoint,
60
+ loginEndpoint: Stdlib_Option.getOr(Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_LOGIN_ENDPOINT"), loginFor(endpoint)),
61
+ origin: "EnvOverride"
62
+ };
63
+ }
64
+ let running = LocalPlatformRegistry$ReventlessLocal.list(undefined, undefined);
65
+ let preselected = Stdlib_Option.flatMap(Seed_Prompt$ReventlessSeed.envValue("SEED_PLATFORM"), v => running.find(e => e.port.toString() === v));
66
+ let chosen = preselected !== undefined ? preselected : (
67
+ running.length !== 0 ? await Seed_Prompt$ReventlessSeed.select("Platform:", running.map(e => [
68
+ entryLabel(e),
69
+ e
70
+ ]), "SEED_PLATFORM") : undefined
71
+ );
72
+ if (chosen !== undefined) {
73
+ return {
74
+ endpoint: chosen.endpoint,
75
+ loginEndpoint: chosen.loginEndpoint,
76
+ origin: {
77
+ TAG: "Running",
78
+ _0: chosen
79
+ }
80
+ };
81
+ } else {
82
+ return {
83
+ endpoint: defaultEndpoint,
84
+ loginEndpoint: loginFor(defaultEndpoint),
85
+ origin: "NoneRunning"
86
+ };
87
+ }
88
+ }
89
+
90
+ function announce(t) {
91
+ let entry = t.origin;
92
+ if (typeof entry !== "object") {
93
+ if (entry === "EnvOverride") {
94
+ console.log(`→ ` + t.endpoint + ` (REVENTLESS_GRAPHQL_ENDPOINT)`);
95
+ return;
96
+ }
97
+ console.log(`→ ` + t.endpoint + ` (no local platform registered here — trying the default)`);
98
+ return;
99
+ } else {
100
+ let entry$1 = entry._0;
101
+ console.log(`→ ` + entry$1.endpoint + ` · ` + storeLabel(entry$1.store) + ` (` + shortAppName(entry$1.app) + `)`);
102
+ return;
103
+ }
104
+ }
105
+
106
+ function storePath(t) {
107
+ let match = t.origin;
108
+ if (typeof match !== "object") {
109
+ if (match === "EnvOverride") {
110
+ return {
111
+ TAG: "Error",
112
+ _0: `REVENTLESS_GRAPHQL_ENDPOINT names ` + t.endpoint + `, which does not say which store that platform opened. Set REVENTLESS_LOCAL_BACKEND to the store file, or unset REVENTLESS_GRAPHQL_ENDPOINT to pick a running platform.`
113
+ };
114
+ } else {
115
+ return {
116
+ TAG: "Error",
117
+ _0: "no local platform is running here. Start one, or set REVENTLESS_LOCAL_BACKEND to reset a store directly."
118
+ };
119
+ }
120
+ }
121
+ let match$1 = match._0;
122
+ let match$2 = match$1.store;
123
+ let kind = match$2.kind;
124
+ let endpoint = match$1.endpoint;
125
+ switch (kind) {
126
+ case "memory" :
127
+ return {
128
+ TAG: "Error",
129
+ _0: `the platform at ` + endpoint + ` keeps its store in memory; restart it to empty it.`
130
+ };
131
+ case "postgres" :
132
+ return {
133
+ TAG: "Error",
134
+ _0: `the platform at ` + endpoint + ` is backed by Postgres, which keeps its event logs off this machine. Reset it against the database.`
135
+ };
136
+ case "sqlite" :
137
+ let path = match$2.path;
138
+ if (path !== undefined) {
139
+ return {
140
+ TAG: "Ok",
141
+ _0: path
142
+ };
143
+ }
144
+ break;
145
+ }
146
+ return {
147
+ TAG: "Error",
148
+ _0: `the platform at ` + endpoint + ` reports an unknown store kind "` + kind + `".`
149
+ };
150
+ }
151
+
152
+ function connect() {
153
+ return async () => {
154
+ let target = await select();
155
+ announce(target);
156
+ return await Seed_Connect$ReventlessSeed.local(target.endpoint, target.loginEndpoint, undefined)();
157
+ };
158
+ }
159
+
160
+ export {
161
+ defaultEndpoint,
162
+ loginFor,
163
+ relativeIfInside,
164
+ storeLabel,
165
+ shortAppName,
166
+ entryLabel,
167
+ select,
168
+ announce,
169
+ storePath,
170
+ connect,
171
+ }
172
+ /* node:path Not a pure module */
package/src/Platform.res CHANGED
@@ -1303,6 +1303,19 @@ module MakeWithConfig = (
1303
1303
  PlatformMCP_Server.printDiagnostics()
1304
1304
  }
1305
1305
  }
1306
+ // Announce this platform — endpoint AND the store it opened — so `seed` and
1307
+ // `seed:reset` can address it instead of inferring a store from their own
1308
+ // environment. Here rather than in makePlatform because this is the one
1309
+ // point both API modes pass through once the servers are actually up: in
1310
+ // split mode they start just above, in unified mode makePlatform started
1311
+ // them and this still runs before the process serves its first request.
1312
+ let (kind, path) = BackendState.describeStore()
1313
+ LocalPlatformRegistry.register(
1314
+ ~port=domainPort,
1315
+ ~endpoint=`http://localhost:${domainPort->Int.toString}/graphql`,
1316
+ ~loginEndpoint=`http://localhost:${domainPort->Int.toString}/__inmemory/login`,
1317
+ ~store={kind, path},
1318
+ )
1306
1319
  // Fire onPlatformDeployed after all servers are started so late-deployed
1307
1320
  // plugins (e.g. PlatformInspector) have their handler refs populated.
1308
1321
  ReventlessCore.Plugin_Helpers.firePlatformDeployedHook({
@@ -2238,7 +2251,7 @@ module MakeWithConfig = (
2238
2251
  // extracts identity from the bearer token, same as the Domain server.
2239
2252
  // Without this, Platform_* queries / mutations run as `anonymous` and
2240
2253
  // skip the group authorization that AppSync would enforce via
2241
- // `@aws_auth(cognito_groups: ["Admin"])` in production. The Domain
2254
+ // `@aws_cognito_user_pools(cognito_groups: ["Admin"])` in production. The Domain
2242
2255
  // server's `asInterface.start` accepts but ignores `~contextFactory`
2243
2256
  // because it always wires its own internal auth context.
2244
2257
  adminGraphQL.start(
@@ -69,6 +69,7 @@ import * as LocalUploadResolvers$ReventlessLocal from "./adapter/LocalUploadReso
69
69
  import * as ProjectionCheckpoint$ReventlessLocal from "./adapter/ProjectionCheckpoint.res.mjs";
70
70
  import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
71
71
  import * as LocalGeocodeResolvers$ReventlessLocal from "./adapter/LocalGeocodeResolvers.res.mjs";
72
+ import * as LocalPlatformRegistry$ReventlessLocal from "./LocalPlatformRegistry.res.mjs";
72
73
  import * as UiFragments_Projection$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments_Projection.res.mjs";
73
74
  import * as EventLogStorage_Sqlite$ReventlessLocal from "./adapter/EventLog/EventLogStorage_Sqlite.res.mjs";
74
75
  import * as ExtensionPoint_Builder$ReventlessLocal from "./components/ExtensionPoint_Builder.res.mjs";
@@ -1105,6 +1106,11 @@ function MakeWithConfig(Config) {
1105
1106
  PlatformMCP_Server$ReventlessLocal.printDiagnostics();
1106
1107
  }
1107
1108
  }
1109
+ let match = BackendState$ReventlessLocal.describeStore();
1110
+ LocalPlatformRegistry$ReventlessLocal.register(domainPort, `http://localhost:` + domainPort.toString() + `/graphql`, `http://localhost:` + domainPort.toString() + `/__inmemory/login`, {
1111
+ kind: match[0],
1112
+ path: match[1]
1113
+ });
1108
1114
  Plugin_Helpers$ReventlessCore.firePlatformDeployedHook({
1109
1115
  name: "local",
1110
1116
  environment: Pulumi.getStack(),
@@ -2826,6 +2832,11 @@ function Make($star) {
2826
2832
  PlatformGraphQL_Server$ReventlessLocal.printDiagnostics();
2827
2833
  PlatformMCP_Server$ReventlessLocal.printDiagnostics();
2828
2834
  }
2835
+ let match = BackendState$ReventlessLocal.describeStore();
2836
+ LocalPlatformRegistry$ReventlessLocal.register(domainPort, `http://localhost:` + domainPort.toString() + `/graphql`, `http://localhost:` + domainPort.toString() + `/__inmemory/login`, {
2837
+ kind: match[0],
2838
+ path: match[1]
2839
+ });
2829
2840
  Plugin_Helpers$ReventlessCore.firePlatformDeployedHook({
2830
2841
  name: "local",
2831
2842
  environment: Pulumi.getStack(),
@@ -6,9 +6,15 @@
6
6
  // - Missing header → `defaultUser`
7
7
  // - Invalid bearer is rejected at HTTP level before this runs (see
8
8
  // `DomainGraphQL_Server._dispatch`); on the admin server there is no
9
- // dispatch layer, so an invalid bearer falls through to `defaultUser`
10
- // too. AppSync enforces the same `@aws_auth(cognito_groups: ["Admin"])`
11
- // directive at the schema layer in production.
9
+ // dispatch layer, so an invalid bearer reaches this as an `AuthError`.
10
+ // AppSync enforces the same `@aws_cognito_user_pools(cognito_groups:
11
+ // ["Admin"])` directive at the schema layer in production.
12
+ //
13
+ // The outcome is carried onto the context beside the identity, not folded into
14
+ // it. Whether credentials verified and whether the verified caller holds a group
15
+ // are different questions with different answers for the client — one is worth
16
+ // retrying with new credentials, the other never is — and a group check that
17
+ // sees only the identity cannot tell which one it is refusing.
12
18
 
13
19
  module YG = GraphqlYoga
14
20
 
@@ -32,13 +38,34 @@ let identityFromAuthResult = (result: Reventless.Identity.authResult): Reventles
32
38
  | AuthError(_) => Reventless.Identity.anonymous
33
39
  }
34
40
 
41
+ /**
42
+ Whether the request presented credentials the adapter accepted.
43
+
44
+ `identityFromAuthResult` cannot answer this: it maps both `Anonymous` and
45
+ `AuthError` onto the same anonymous identity, so by the time a resolver holds
46
+ an identity, "nobody presented credentials", "the credentials did not verify"
47
+ and "credentials verified for someone without the group" are one shape. That
48
+ collapse is what made a group check unable to say which of them it was
49
+ refusing.
50
+
51
+ A request carrying no `Authorization` header at all counts as authenticated
52
+ here, because `LocalAuth.authenticate` answers it with `defaultUser` by
53
+ design — in-memory mode decides such a caller is somebody, and this reports
54
+ the decision rather than second-guessing it.
55
+ */
56
+ let isAuthenticated = (result: Reventless.Identity.authResult): bool =>
57
+ switch result {
58
+ | Authenticated(_) => true
59
+ | Anonymous | AuthError(_) => false
60
+ }
61
+
35
62
  let buildAuthContext = async (initial: YG.initialContext): JSON.t => {
36
63
  let ctx: yogaInitialCtx = Obj.magic(initial)
37
64
  let headers = extractHeaders(ctx.request.headers)
38
65
  let requestContext: ReventlessCore.Auth_Adapter.requestContext = {headers: headers}
39
66
  let result = await LocalAuth.authenticate(requestContext)
40
67
  let identity = identityFromAuthResult(result)
41
- Obj.magic({"identity": identity})
68
+ Obj.magic({"identity": identity, "authenticated": isAuthenticated(result)})
42
69
  }
43
70
 
44
71
  // Read the identity attached by `buildAuthContext` from the resolver context.
@@ -54,27 +81,71 @@ let extractIdentity = (ctx: JSON.t): Reventless.Identity.t =>
54
81
  | _ => Reventless.Identity.anonymous
55
82
  }
56
83
 
84
+ // Read the authentication outcome `buildAuthContext` recorded. Falls back to
85
+ // `false` for the same reasons `extractIdentity` falls back to anonymous, and
86
+ // with the same conservatism: a context this cannot read is one that proves
87
+ // nothing about the caller, and "unauthenticated" is the answer that asks them
88
+ // to present credentials rather than telling them theirs were rejected.
89
+ let extractAuthenticated = (ctx: JSON.t): bool =>
90
+ try {
91
+ switch (ctx->Obj.magic)["authenticated"]->Nullable.toOption {
92
+ | Some(authenticated) => (authenticated: bool)
93
+ | None => false
94
+ }
95
+ } catch {
96
+ | _ => false
97
+ }
98
+
57
99
  // An unauthorized caller reads the reason: `GraphQL_CallerError` explains why a
58
- // resolver has to construct the error rather than throw a bare one. Mirrors the
59
- // directive-level `@aws_auth` rejection that AppSync surfaces in production.
100
+ // resolver has to construct the error rather than throw a bare one. The AWS
101
+ // counterpart is the field-level refusal AppSync returns for a caller who fails
102
+ // an `@aws_cognito_user_pools(cognito_groups: [...])` gate.
60
103
  let makeGraphqlError = GraphQL_CallerError.make
61
104
 
105
+ /**
106
+ Nobody the server could identify asked for a gated field.
107
+
108
+ Keeps the code it has always carried. A client reading only `UNAUTHORIZED` is
109
+ one that treats it as "your credentials are not being honoured", and narrowing
110
+ the code to the case where that is true makes such a client correct rather than
111
+ breaking it.
112
+ */
62
113
  let unauthorizedError = (~group: string): exn =>
63
114
  makeGraphqlError(
64
115
  `Unauthorized: requires group "${group}"`,
65
- {"extensions": {"code": "UNAUTHORIZED"}},
116
+ {"extensions": {"code": ReventlessCore.Auth_RefusalVocabulary.localIdentityCode}},
117
+ )
118
+
119
+ /**
120
+ Somebody the server identified asked for a field their groups do not cover.
121
+
122
+ Separate from `unauthorizedError` because the two ask for different things and
123
+ a client cannot tell them apart from the refusal alone. Answering both with one
124
+ code left every caller to guess, and the guess that fits an expired token —
125
+ discard the session and ask them to sign in again — ends a working session for
126
+ a caller who was simply not entitled to the field. Presenting credentials again
127
+ cannot change this answer, which is what the distinct code says.
128
+ */
129
+ let forbiddenError = (~group: string): exn =>
130
+ makeGraphqlError(
131
+ `Forbidden: requires group "${group}"`,
132
+ {"extensions": {"code": ReventlessCore.Auth_RefusalVocabulary.localEntitlementCode}},
66
133
  )
67
134
 
68
- // Wrap a resolver so it raises a GraphQL "Unauthorized" error when the
69
- // requesting identity lacks the required group. Mirrors AppSync's
70
- // `@aws_auth(cognito_groups: [...])` semantics for admin fields on the
71
- // in-memory adapter. Use for fields whose corresponding read-model entry
135
+ // Wrap a resolver so it refuses a caller whose identity lacks the required
136
+ // group. Mirrors AppSync's `@aws_cognito_user_pools(cognito_groups: [...])`
137
+ // semantics for admin fields on the in-memory adapter. Both paths draw the same
138
+ // two-way distinction AppSync as HTTP 401 versus a field error, this as two
139
+ // `extensions.code` values — and `ReventlessCore.Auth_RefusalVocabulary` is the
140
+ // mapping between them. Use for fields whose corresponding read-model entry
72
141
  // carries `authorization: Some({group, ...})` — pass the group string here.
73
142
  let requireGroup = (~group: string, resolver: YG.resolverFn): YG.resolverFn =>
74
143
  async (root, args, ctx) => {
75
144
  let identity = extractIdentity(ctx)
76
145
  if identity.groups->Array.includes(group) {
77
146
  await resolver(root, args, ctx)
147
+ } else if extractAuthenticated(ctx) {
148
+ throw(forbiddenError(~group))
78
149
  } else {
79
150
  throw(unauthorizedError(~group))
80
151
  }
@@ -3,6 +3,7 @@
3
3
  import * as Graphql from "graphql";
4
4
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
5
5
  import * as LocalAuth$ReventlessLocal from "./LocalAuth.res.mjs";
6
+ import * as Auth_RefusalVocabulary$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_RefusalVocabulary.res.mjs";
6
7
 
7
8
  function extractHeaders(headers) {
8
9
  let acc = {};
@@ -20,14 +21,24 @@ function identityFromAuthResult(result) {
20
21
  }
21
22
  }
22
23
 
24
+ function isAuthenticated(result) {
25
+ if (typeof result !== "object") {
26
+ return false;
27
+ } else {
28
+ return result.TAG === "Authenticated";
29
+ }
30
+ }
31
+
23
32
  async function buildAuthContext(initial) {
24
33
  let headers = extractHeaders(initial.request.headers);
25
34
  let requestContext = {
26
35
  headers: headers
27
36
  };
28
37
  let result = await LocalAuth$ReventlessLocal.authenticate(requestContext);
38
+ let identity = identityFromAuthResult(result);
29
39
  return {
30
- identity: identityFromAuthResult(result)
40
+ identity: identity,
41
+ authenticated: isAuthenticated(result)
31
42
  };
32
43
  }
33
44
 
@@ -44,6 +55,19 @@ function extractIdentity(ctx) {
44
55
  }
45
56
  }
46
57
 
58
+ function extractAuthenticated(ctx) {
59
+ try {
60
+ let authenticated = ctx.authenticated;
61
+ if (authenticated == null) {
62
+ return false;
63
+ } else {
64
+ return authenticated;
65
+ }
66
+ } catch (exn) {
67
+ return false;
68
+ }
69
+ }
70
+
47
71
  function makeGraphqlError(prim0, prim1) {
48
72
  return new Graphql.GraphQLError(prim0, prim1);
49
73
  }
@@ -51,7 +75,15 @@ function makeGraphqlError(prim0, prim1) {
51
75
  function unauthorizedError(group) {
52
76
  return new Graphql.GraphQLError(`Unauthorized: requires group "` + group + `"`, {
53
77
  extensions: {
54
- code: "UNAUTHORIZED"
78
+ code: Auth_RefusalVocabulary$ReventlessCore.localIdentityCode
79
+ }
80
+ });
81
+ }
82
+
83
+ function forbiddenError(group) {
84
+ return new Graphql.GraphQLError(`Forbidden: requires group "` + group + `"`, {
85
+ extensions: {
86
+ code: Auth_RefusalVocabulary$ReventlessCore.localEntitlementCode
55
87
  }
56
88
  });
57
89
  }
@@ -62,6 +94,9 @@ function requireGroup(group, resolver) {
62
94
  if (identity.groups.includes(group)) {
63
95
  return await resolver(root, args, ctx);
64
96
  }
97
+ if (extractAuthenticated(ctx)) {
98
+ throw forbiddenError(group);
99
+ }
65
100
  throw unauthorizedError(group);
66
101
  };
67
102
  }
@@ -72,10 +107,13 @@ export {
72
107
  YG,
73
108
  extractHeaders,
74
109
  identityFromAuthResult,
110
+ isAuthenticated,
75
111
  buildAuthContext,
76
112
  extractIdentity,
113
+ extractAuthenticated,
77
114
  makeGraphqlError,
78
115
  unauthorizedError,
116
+ forbiddenError,
79
117
  requireGroup,
80
118
  }
81
119
  /* graphql Not a pure module */
@@ -50,3 +50,18 @@ let getObjectStoreRoot = () =>
50
50
  | Sqlite({path}) if path != ":memory:" => Some(NodePath.dirname(path))
51
51
  | Sqlite(_) | Memory | Postgres(_) => None
52
52
  }
53
+
54
+ // How the active backend names itself to a tool outside this process — the
55
+ // answer `LocalPlatformRegistry` publishes so `seed` and `seed:reset` stop
56
+ // inferring it from their own environment.
57
+ //
58
+ // Absolute, because the platform resolved `./.reventless/local.db` against ITS
59
+ // cwd and a reader may not share it. `:memory:` reports "memory": there is no
60
+ // file to open, which is the only distinction that changes what a tool can do.
61
+ let describeStore = (): (string, option<string>) =>
62
+ switch current.contents {
63
+ | Memory => ("memory", None)
64
+ | Sqlite({path}) if path == ":memory:" => ("memory", None)
65
+ | Sqlite({path}) => ("sqlite", Some(NodePath.resolve([path])))
66
+ | Postgres(_) => ("postgres", None)
67
+ }
@@ -58,6 +58,34 @@ function getObjectStoreRoot() {
58
58
  }
59
59
  }
60
60
 
61
+ function describeStore() {
62
+ let match = current.contents;
63
+ if (typeof match !== "object") {
64
+ return [
65
+ "memory",
66
+ undefined
67
+ ];
68
+ }
69
+ if (match.TAG !== "Sqlite") {
70
+ return [
71
+ "postgres",
72
+ undefined
73
+ ];
74
+ }
75
+ let path = match.path;
76
+ if (path === ":memory:") {
77
+ return [
78
+ "memory",
79
+ undefined
80
+ ];
81
+ } else {
82
+ return [
83
+ "sqlite",
84
+ Nodepath.resolve(path)
85
+ ];
86
+ }
87
+ }
88
+
61
89
  export {
62
90
  current,
63
91
  setMemory,
@@ -66,5 +94,6 @@ export {
66
94
  getSqliteDb,
67
95
  getPostgresPool,
68
96
  getObjectStoreRoot,
97
+ describeStore,
69
98
  }
70
99
  /* node:path Not a pure module */
@@ -10,10 +10,15 @@
10
10
  //
11
11
  // Authorization: every query AND mutation resolver registered here is
12
12
  // wrapped with `Auth_GraphqlContext.requireGroup(~group="Admin")` so
13
- // non-Admin identities raise an `UNAUTHORIZED` GraphQLError before the
14
- // underlying resolver runs. Mirrors AppSync's
15
- // `@aws_auth(cognito_groups: ["Admin"])` directive that gates Platform_*
16
- // fields in the AWS adapter.
13
+ // non-Admin identities are refused before the underlying resolver runs.
14
+ // Mirrors AppSync's `@aws_cognito_user_pools(cognito_groups: ["Admin"])`
15
+ // directive that gates Platform_* fields in the AWS adapter.
16
+ //
17
+ // The refusal names which kind it is: `FORBIDDEN` for a caller the server
18
+ // identified who does not hold the group, `UNAUTHORIZED` for one it could not
19
+ // identify at all. Every surface here is admin-gated, so a client discovering
20
+ // through them meets this refusal as a matter of course rather than as a
21
+ // failure, and cannot afford to read it as a session that has ended.
17
22
  //
18
23
  // Mutations are wrapped (in addition to their per-command
19
24
  // `commandAuthorization` rule inside `CommandGeneratorResolvers_GraphQL.register`)
@@ -476,30 +476,52 @@ let scopeOptions = (plugins: array<string>): array<(string, scope)> =>
476
476
  [("platform", Platform), ("everything", Everything)],
477
477
  )
478
478
 
479
- /** Runs the reset against the store the local platform would open.
480
-
481
- `dbPath` defaults to what `REVENTLESS_LOCAL_BACKEND` selects, so the tool and
482
- the platform cannot disagree about which store is "the" store. A Memory or
483
- Postgres backend has no local file to reset and says so rather than appearing
484
- to work. */
479
+ /** Runs the reset against the store the selected platform actually opened.
480
+
481
+ Resolution order, and why it is this way round: the store used to be read off
482
+ `REVENTLESS_LOCAL_BACKEND` in THIS process, which describes no platform at
483
+ all. With a second platform up the VS Code runner beside a hand-started one
484
+ that emptied a database nobody was serving while reporting success, and the
485
+ `seed` that follows then refused because the served store was still full. So
486
+ the running platform is asked instead, and the variable is kept only for the
487
+ case discovery cannot serve: a store whose platform is down.
488
+
489
+ 1. `~dbPath` — a programmatic caller has already decided.
490
+ 2. `REVENTLESS_LOCAL_BACKEND` **set in this shell** — an explicit target.
491
+ 3. the platform selected by {!LocalSeedTarget.select} — the normal path.
492
+
493
+ Note for callers wiring this into a package script: do NOT default the
494
+ variable there (`${REVENTLESS_LOCAL_BACKEND:-sqlite:./…}`). It would make
495
+ step 2 always fire, and that default is the very guess this replaces. */
485
496
  let run = (~dbPath: option<string>=?): unit => {
486
497
  let go = async () => {
487
- let resolved = switch dbPath {
488
- | Some(p) => Some(p)
489
- | None =>
498
+ let fromBackendEnv = () =>
490
499
  switch Backend.fromEnv() {
491
- | Backend.Sqlite({path}) if path != ":memory:" => Some(path)
500
+ | Backend.Sqlite({path}) if path != ":memory:" => Ok(path)
492
501
  | Backend.Sqlite(_) | Backend.Memory =>
493
- Console.log(
494
- "Nothing to reset — REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.",
502
+ Error(
503
+ "REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.",
495
504
  )
496
- None
497
505
  | Backend.Postgres(_) =>
498
- Console.log(
499
- "Nothing to reset here — the Postgres backend keeps its event logs off this machine. Reset it against the database.",
506
+ Error(
507
+ "the Postgres backend keeps its event logs off this machine. Reset it against the database.",
500
508
  )
501
- None
502
509
  }
510
+
511
+ let resolved = switch (dbPath, Seed.Prompt.envValue("REVENTLESS_LOCAL_BACKEND")) {
512
+ | (Some(p), _) => Ok(p)
513
+ | (None, Some(_)) => fromBackendEnv()
514
+ | (None, None) =>
515
+ let target = await LocalSeedTarget.select()
516
+ target->LocalSeedTarget.announce
517
+ target->LocalSeedTarget.storePath
518
+ }
519
+
520
+ let resolved = switch resolved {
521
+ | Ok(path) => Some(path)
522
+ | Error(reason) =>
523
+ Console.log(`Nothing to reset — ${reason}`)
524
+ None
503
525
  }
504
526
 
505
527
  switch resolved {