@sasjs/cli 6.25.0 → 6.26.1

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.
@@ -13,19 +13,21 @@
13
13
  * TRUST MODEL: this is a single-user host on the site's own origin. It answers
14
14
  * the API the way a SASjs server does, but there is no boundary behind it: a page
15
15
  * at this origin can already reach everything here, because a visitor's browser
16
- * is the client. The identity is the package's demo fixture (@sasjs/api's
17
- * `DEFAULT_IDENTITY`: `sasjsadmin` and `sasjsuser`, both `Orion123`), and a
18
- * request that carries no credential is read as the fixture administrator - the
19
- * visitor, on a host with no login wall. A site holding anything private, or one
20
- * served from a shared origin, belongs behind a real server rather than a static
21
- * host.
16
+ * is the client. The identity is a fixture the build ships as `host/identity.json`
17
+ * (@sasjs/api's demo fixture unless the project supplies its own: `sasjsadmin`
18
+ * and `sasjsuser`, both `Orion123`), and a request that carries no credential is
19
+ * read as the fixture administrator - the visitor, on a host with no login wall.
20
+ * The subject is switchable (`window.SASjsMock.as('sasjsuser')`) so a reviewer can
21
+ * see the refusals a restricted account gets. Passwords are plain text in the
22
+ * fixture and tokens are opaque strings nobody signs: this is a fixture, not an
23
+ * identity provider, and a site holding anything private, or one served from a
24
+ * shared origin, belongs behind a real server rather than a static host.
22
25
  *
23
- * The drive is persisted by the page (host/persist.js) and the identity is not:
24
- * an account, group, permission rule or client created through these routes
25
- * lives in this worker's memory, so a worker the browser terminates starts again
26
- * from the fixture. Edits that must survive a restart belong on a server. The
27
- * activity log the Admin routes report is in this worker's memory for the same
28
- * reason.
26
+ * The drive and the identity are both persisted by the page (host/persist.js): an
27
+ * account, group, permission rule or client created through these routes survives
28
+ * a reload because the page stores the store's own snapshot, and a visitor who
29
+ * chose a subject stays that subject. The activity log the Admin routes report is
30
+ * in this worker's memory, so it restarts with the worker.
29
31
  *
30
32
  * Every operation in @sasjs/api's route table is answered here: the drive, the
31
33
  * sign-in flow, the records, the runs, the sessions, the diagnostics and logs,
@@ -51,23 +53,168 @@ const API = self.SASjsApi
51
53
  const CODE = self.SASjsCode
52
54
 
53
55
  const DRIVE_STORE = R.driveStore
54
- const IDENTITY_STORE = new API.MemoryIdentityStore(API.DEFAULT_IDENTITY)
56
+
57
+ /**
58
+ * The identity this site signs in with.
59
+ *
60
+ * It ships as `host/identity.json`, which the build writes from the package's
61
+ * demo fixture unless the project drops a `mock-identity.json` beside the build.
62
+ * One file, one load path: the runtime reads the fixture rather than holding an
63
+ * object literal, so the documented format is the one in use, a project replaces
64
+ * it without touching this file, and which fixture wins is decided at build time
65
+ * rather than at run time.
66
+ *
67
+ * The store sits behind a port because everything below is built once, while the
68
+ * fixture arrives after this module is evaluated (it is a fetch). `SERVICES`,
69
+ * `IDENTITY` and `RECORDS` therefore speak to whichever store is current, which
70
+ * is what lets a boot replace the fixture, `as()` re-point the caller, and
71
+ * `reset()` put the shipped one back.
72
+ */
73
+ const IDENTITY_FILE = 'host/identity.json'
74
+
75
+ const IDENTITY_STATE = {
76
+ store: new API.MemoryIdentityStore(API.DEFAULT_IDENTITY),
77
+ /** The fixture the store last reported to the page, as JSON. */
78
+ persisted: '',
79
+ /** The fixture the build shipped; `reset()` returns to this one. */
80
+ shipped: null,
81
+ /** True once a fixture has been seeded, so a boot is not overwritten. */
82
+ seeded: false
83
+ }
84
+
85
+ const IDENTITY_STORE = new Proxy(
86
+ {},
87
+ {
88
+ get:
89
+ (_target, method) =>
90
+ (...args) =>
91
+ IDENTITY_STATE.store[method](...args)
92
+ }
93
+ )
55
94
  const IDENTITY = API.createIdentityService(IDENTITY_STORE)
56
95
  const RECORDS = API.createRecordService(IDENTITY_STORE)
57
96
 
97
+ const adminUser = () =>
98
+ IDENTITY_STATE.store.snapshot().users.find((user) => user.isAdmin)
99
+
100
+ const firstClient = () => (IDENTITY_STATE.store.snapshot().clients || [])[0]
101
+
58
102
  /**
59
103
  * The caller a request without a credential is talking as.
60
104
  *
61
105
  * This host has no login wall - that is its trust model, not an oversight - so
62
- * a page that does not sign in is not anonymous, it is the visitor. The fixture
63
- * administrator is the account the demo exists to give a client, and naming it
64
- * here keeps a mock site usable with no `/SASLogon/login` call. A request that
65
- * does carry a token is that token's subject, so the sign-in flow, the fixture's
66
- * two accounts and their permission rules all behave as the package decides.
106
+ * a page that does not sign in is not anonymous, it is the visitor. `undefined`
107
+ * is the fixture's administrator, a username is that account, and `null` means
108
+ * no caller at all: the last is how the conformance corpus asks for the answers
109
+ * an anonymous request gets, which a static host otherwise never produces.
110
+ * Naming the account here keeps a mock site usable with no `/SASLogon/login`
111
+ * call, while giving `as()` something to change.
112
+ */
113
+ let CALLER_USERNAME
114
+ let IMPLICIT_CALLER
115
+
116
+ /** Names the caller from the current fixture, resolving it to a subject. */
117
+ const resolveCaller = () => {
118
+ const user =
119
+ CALLER_USERNAME === null
120
+ ? undefined
121
+ : CALLER_USERNAME === undefined
122
+ ? adminUser()
123
+ : IDENTITY_STATE.store
124
+ .snapshot()
125
+ .users.find((candidate) => candidate.username === CALLER_USERNAME)
126
+
127
+ const client = firstClient()
128
+
129
+ IMPLICIT_CALLER =
130
+ user && client ? { userId: user.uid, clientId: client.clientId } : undefined
131
+ }
132
+
133
+ const seedIdentity = (fixture) => {
134
+ IDENTITY_STATE.store = new API.MemoryIdentityStore(fixture)
135
+ IDENTITY_STATE.persisted = JSON.stringify(IDENTITY_STATE.store.snapshot())
136
+ IDENTITY_STATE.seeded = true
137
+ resolveCaller()
138
+ }
139
+
140
+ /**
141
+ * The fixture the build ships, read once from the site.
142
+ *
143
+ * A build always writes the file, so a malformed one is refused by name: a
144
+ * store seeded from a fixture whose format this package cannot read would answer
145
+ * the sign-in flow with accounts it half-understands. An ABSENT file is the one
146
+ * case that falls back - a site deployed from a build before the fixture existed
147
+ * still runs, as the demo fixture, with the fact written to the host log rather
148
+ * than passing silently.
149
+ */
150
+ let SHIPPED = null
151
+ const shippedIdentity = () => {
152
+ if (!SHIPPED)
153
+ SHIPPED = (async () => {
154
+ const response = await fetch(IDENTITY_FILE, { cache: 'no-store' })
155
+
156
+ if (!response.ok) {
157
+ logEntry(
158
+ `identity ${IDENTITY_FILE} answered ${response.status}: using the packaged demo fixture`
159
+ )
160
+ IDENTITY_STATE.shipped = API.DEFAULT_IDENTITY
161
+ return IDENTITY_STATE.shipped
162
+ }
163
+
164
+ const raw = await response.json()
165
+ try {
166
+ IDENTITY_STATE.shipped = API.parseIdentity(raw)
167
+ } catch (e) {
168
+ throw new Error(
169
+ `${IDENTITY_FILE} cannot be used: ${(e && e.message) || String(e)}`
170
+ )
171
+ }
172
+ return IDENTITY_STATE.shipped
173
+ })()
174
+ return SHIPPED
175
+ }
176
+
177
+ let IDENTITY_READY = null
178
+ /** The identity is usable once a fixture has been seeded. */
179
+ const ensureIdentity = () => {
180
+ if (!IDENTITY_READY)
181
+ IDENTITY_READY = shippedIdentity().then((fixture) => {
182
+ if (!IDENTITY_STATE.seeded) seedIdentity(fixture)
183
+ return fixture
184
+ })
185
+ return IDENTITY_READY
186
+ }
187
+
188
+ /**
189
+ * Boots the identity the page hands over: the fixture it persisted (a store
190
+ * written by an older build is refused by name by `parseIdentity`) and the
191
+ * subject the visitor last chose.
67
192
  */
68
- const IMPLICIT_CALLER = {
69
- userId: (API.DEFAULT_IDENTITY.users.find((user) => user.isAdmin) || {}).uid,
70
- clientId: ((API.DEFAULT_IDENTITY.clients || [])[0] || {}).clientId
193
+ const bootIdentity = async (data) => {
194
+ await ensureIdentity()
195
+
196
+ let fixture = IDENTITY_STATE.shipped
197
+
198
+ if (data.identity) {
199
+ try {
200
+ fixture = API.parseIdentity(data.identity)
201
+ } catch (e) {
202
+ // tagged so the page can tell a fixture IT cannot read (which it drops)
203
+ // from the host failing for some other reason (which it must not)
204
+ const rejected = new Error((e && e.message) || String(e))
205
+ rejected.identityRejected = true
206
+ throw rejected
207
+ }
208
+ }
209
+
210
+ CALLER_USERNAME =
211
+ data.subject === null
212
+ ? null
213
+ : data.subject === undefined
214
+ ? undefined
215
+ : String(data.subject)
216
+
217
+ seedIdentity(fixture)
71
218
  }
72
219
 
73
220
  const LOGS_SEPARATOR = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
@@ -227,10 +374,27 @@ const broadcast = async (message) => {
227
374
  clients.forEach((client) => client.postMessage(message))
228
375
  }
229
376
 
377
+ /**
378
+ * Hands the page what changed: the drive files the runtime marked dirty, and the
379
+ * identity store's own snapshot when a route changed it.
380
+ *
381
+ * The identity travels as the fixture `snapshot()` emits, which is exactly what
382
+ * a boot seeds from - one shape for the file a person reads, the object the page
383
+ * stores and the store it seeds. It is sent only when it differs from the last
384
+ * report, so a drive-only request carries no fixture and an identity-only change
385
+ * still reaches storage.
386
+ */
230
387
  const persistChanges = async () => {
231
388
  const files = R.takeDirty()
232
- if (Object.keys(files).length)
233
- await broadcast({ type: 'sasjs-persist', files })
389
+ const fixture = IDENTITY_STATE.store.snapshot()
390
+ const asJson = IDENTITY_STATE.seeded ? JSON.stringify(fixture) : ''
391
+ const identity =
392
+ asJson && asJson !== IDENTITY_STATE.persisted ? fixture : null
393
+
394
+ if (identity) IDENTITY_STATE.persisted = asJson
395
+
396
+ if (Object.keys(files).length || identity)
397
+ await broadcast({ type: 'sasjs-persist', files, identity })
234
398
  }
235
399
 
236
400
  // ----------------------------------------------------------- the SAS transcript
@@ -522,12 +686,12 @@ const adminReports = {
522
686
  */
523
687
  const directorySource = {
524
688
  users: async () =>
525
- API.DEFAULT_IDENTITY.users.map((user) => ({
689
+ IDENTITY_STATE.store.snapshot().users.map((user) => ({
526
690
  username: user.username,
527
691
  displayName: user.displayName
528
692
  })),
529
693
  groups: async () =>
530
- API.DEFAULT_IDENTITY.groups.map((group) => ({
694
+ IDENTITY_STATE.store.snapshot().groups.map((group) => ({
531
695
  name: group.name,
532
696
  members: group.users || []
533
697
  }))
@@ -536,15 +700,23 @@ const directorySource = {
536
700
  /**
537
701
  * The app streams this host published: the site itself.
538
702
  *
703
+ * The index is the package's, so the contract is the package's - an app is its
704
+ * id, one app per `appLoc`, and unpublish takes the id (sasjs/server#544). The id
705
+ * is fixed here rather than minted: this host publishes exactly one app, the
706
+ * folder the site was built for, and a stable id is a handle a test and a
707
+ * reviewer can both name.
708
+ *
539
709
  * The folder is the identity, so unpublishing the app the site was built for
540
- * answers `success`; any other folder is the documented 404. The static files
710
+ * answers with that app; any other id is the documented 404. The static files
541
711
  * stay where they are - a worker cannot delete the site it is served from - so
542
712
  * this is a record of the publication, not a filesystem operation.
543
713
  */
544
- let registryPromise
545
- const registry = () => {
546
- if (!registryPromise)
547
- registryPromise = (async () => {
714
+ const SITE_APP_ID = '6f1c1c1e-6a1e-4c4a-9d1a-1b2c3d4e5f60'
715
+
716
+ let appStreamPromise
717
+ const appStreamService = () => {
718
+ if (!appStreamPromise)
719
+ appStreamPromise = (async () => {
548
720
  let appLoc
549
721
  try {
550
722
  const config = await (await fetch('host/mock-config.json')).json()
@@ -552,11 +724,37 @@ const registry = () => {
552
724
  } catch (e) {
553
725
  appLoc = undefined
554
726
  }
555
- return new API.MemoryAppStreamRegistry(appLoc ? [appLoc] : [])
727
+
728
+ return API.createAppStreamService(
729
+ new API.MemoryAppStreamStore(
730
+ appLoc
731
+ ? [
732
+ {
733
+ id: SITE_APP_ID,
734
+ name: API.defaultAppName(appLoc),
735
+ appLoc,
736
+ streamWebFolder: 'web',
737
+ publishedAt: new Date(0).toISOString()
738
+ }
739
+ ]
740
+ : []
741
+ )
742
+ )
556
743
  })()
557
- return registryPromise
744
+ return appStreamPromise
558
745
  }
559
746
 
747
+ /** The package's service, built once the config names the folder. */
748
+ const APP_STREAM = new Proxy(
749
+ {},
750
+ {
751
+ get:
752
+ (_target, method) =>
753
+ (...args) =>
754
+ appStreamService().then((service) => service[method](...args))
755
+ }
756
+ )
757
+
560
758
  /**
561
759
  * The page at `/`.
562
760
  *
@@ -602,9 +800,7 @@ const SERVICES = {
602
800
  authConfig: API.createAuthConfigService({}),
603
801
  admin: API.createAdminService(adminReports),
604
802
  directory: API.createDirectoryService(IDENTITY_STORE, directorySource),
605
- registry: {
606
- unpublish: async (appLoc) => (await registry()).unpublish(appLoc)
607
- },
803
+ appStream: APP_STREAM,
608
804
  home: homeService,
609
805
  // the package's own code service over the bundled linter; the host passes no
610
806
  // lint config, so a buffer is linted against the linter's defaults - a mock
@@ -621,6 +817,23 @@ const RUN_ROUTES = new Set([
621
817
  'POST /SASjsApi/code/trigger'
622
818
  ])
623
819
 
820
+ /**
821
+ * The routes that resolve their own caller from the token, rather than being
822
+ * given one the transport resolved.
823
+ *
824
+ * `/auth/refresh` reads the bearer as a REFRESH token and `/auth/token` reads a
825
+ * code, so reading them as access tokens first would refuse the very request the
826
+ * route exists to answer - sasjs/server mounts these ahead of its
827
+ * `authenticateAccessToken` middleware for the same reason.
828
+ */
829
+ const SELF_AUTH_ROUTES = new Set([
830
+ 'POST /SASjsApi/auth/token',
831
+ 'POST /SASjsApi/auth/refresh',
832
+ 'POST /SASjsApi/auth/logout',
833
+ 'POST /SASLogon/login',
834
+ 'GET /SASLogon/logout'
835
+ ])
836
+
624
837
  const SESSION_OUTPUT = 'GET /SASjsApi/session/{sessionId}/output'
625
838
  const DEPLOY_UPLOAD = 'POST /SASjsApi/drive/deploy/upload'
626
839
 
@@ -881,24 +1094,29 @@ const deployTree = (entries) => {
881
1094
  parent.members.push({
882
1095
  name: cut < 0 ? entry.name : entry.name.slice(cut + 1),
883
1096
  type: 'file',
884
- code: toBase64(entry.contents),
885
- // The drive's file model carries text by default and base64 only when the
886
- // member says so (@sasjs/api 0.24.0); an archive's entries are bytes.
887
- encoding: 'base64'
1097
+ // an archive carries bytes, so the member says so: without the stamp the
1098
+ // package writes the base64 TEXT as the file's content (the encoding field
1099
+ // is how a member declares its own representation)
1100
+ encoding: 'base64',
1101
+ code: toBase64(entry.contents)
888
1102
  })
889
1103
  })
890
1104
  return root
891
1105
  }
892
1106
 
893
1107
  // ---------------------------------------------------------------- the routes
1108
+ /** The bearer token a request carries, if it carries one. */
1109
+ const bearerOf = (request, query) =>
1110
+ (request.headers.get('authorization') || '')
1111
+ .replace(/^Bearer\s+/i, '')
1112
+ .trim() ||
1113
+ String(query.access_token || '').trim() ||
1114
+ undefined
1115
+
894
1116
  /** The subject a request is talking as, from its bearer token or the implicit
895
1117
  * caller when it carries none. An unknown token is the package's 401. */
896
1118
  const subjectOf = async (request, query) => {
897
- const header = request.headers.get('authorization') || ''
898
- const token =
899
- header.replace(/^Bearer\s+/i, '').trim() ||
900
- String(query.access_token || '').trim() ||
901
- undefined
1119
+ const token = bearerOf(request, query)
902
1120
  if (!token) return IMPLICIT_CALLER
903
1121
  const subject = await IDENTITY.authenticate(token)
904
1122
  if (!subject) throw new API.ApiError(401, 'Unauthorized')
@@ -1015,6 +1233,10 @@ const routeRequest = async (request, url) => {
1015
1233
  for (const [k, v] of url.searchParams.entries()) query[k] = v
1016
1234
 
1017
1235
  try {
1236
+ // the identity is a file the build shipped, so it is read before anything
1237
+ // that needs a caller - a request that arrives before the page has booted
1238
+ // (a static asset fetch raced by an API call) must not answer as nobody
1239
+ await ensureIdentity()
1018
1240
  await R.ensureReady()
1019
1241
 
1020
1242
  // The app stream is the server's own web surface rather than part of the
@@ -1080,7 +1302,10 @@ const routeRequest = async (request, url) => {
1080
1302
  return json({ message: 'Not part of the API: ' + apiPath }, 404)
1081
1303
 
1082
1304
  const routeId = matched.route.id
1083
- const subject = await subjectOf(request, query)
1305
+ const token = bearerOf(request, query)
1306
+ const subject = SELF_AUTH_ROUTES.has(routeId)
1307
+ ? undefined
1308
+ : await subjectOf(request, query)
1084
1309
  const payload = await readPayload(request, routeId === DEPLOY_UPLOAD)
1085
1310
  payload.query = query
1086
1311
 
@@ -1091,6 +1316,7 @@ const routeRequest = async (request, url) => {
1091
1316
  query,
1092
1317
  params: matched.params,
1093
1318
  subject,
1319
+ token,
1094
1320
  body: payload.body
1095
1321
  }
1096
1322
  const file = singleFile(payload)
@@ -1229,39 +1455,169 @@ self.addEventListener('message', (event) => {
1229
1455
  switch (data.type) {
1230
1456
  case 'sasjs-load':
1231
1457
  event.waitUntil(
1232
- R.load(data.files || {}).then(async () => {
1233
- await persistChanges()
1234
- reply({
1235
- type: 'sasjs-load-ack',
1236
- boot: R.bootInfo(),
1237
- stats: R.stats()
1458
+ bootIdentity(data)
1459
+ .then(() => R.load(data.files || {}))
1460
+ .then(async () => {
1461
+ await persistChanges()
1462
+ reply({
1463
+ type: 'sasjs-load-ack',
1464
+ boot: R.bootInfo(),
1465
+ stats: R.stats()
1466
+ })
1238
1467
  })
1239
- })
1468
+ .catch((e) =>
1469
+ // a persisted fixture this build cannot read is an answer, not a
1470
+ // crash: the page drops it and boots from the fixture the site ships.
1471
+ // `errorKind` is what tells it which failure this is - a drive that
1472
+ // could not be read is not a fixture that could not be read, and
1473
+ // dropping the identity for it would discard the visitor's accounts
1474
+ reply({
1475
+ type: 'sasjs-load-ack',
1476
+ error: (e && e.message) || String(e),
1477
+ errorKind: e && e.identityRejected ? 'identity' : 'host'
1478
+ })
1479
+ )
1240
1480
  )
1241
1481
  break
1242
1482
 
1243
1483
  case 'sasjs-seed':
1244
1484
  event.waitUntil(
1245
- R.seed().then(async () => {
1246
- await persistChanges()
1247
- reply({
1248
- type: 'sasjs-seed-ack',
1249
- boot: R.bootInfo(),
1250
- stats: R.stats()
1485
+ bootIdentity(data)
1486
+ .then(() => R.seed())
1487
+ .then(async () => {
1488
+ await persistChanges()
1489
+ reply({
1490
+ type: 'sasjs-seed-ack',
1491
+ boot: R.bootInfo(),
1492
+ stats: R.stats()
1493
+ })
1251
1494
  })
1252
- })
1495
+ .catch((e) =>
1496
+ reply({
1497
+ type: 'sasjs-seed-ack',
1498
+ error: (e && e.message) || String(e),
1499
+ errorKind: e && e.identityRejected ? 'identity' : 'host'
1500
+ })
1501
+ )
1253
1502
  )
1254
1503
  break
1255
1504
 
1256
1505
  case 'sasjs-reset':
1257
1506
  event.waitUntil(
1258
- R.reset().then(async () => {
1259
- await persistChanges()
1260
- reply({ type: 'sasjs-reset-ack', boot: R.bootInfo() })
1261
- })
1507
+ ensureIdentity()
1508
+ .then(() => {
1509
+ // the shipped fixture and the fixture's own administrator: a reset
1510
+ // is the state a first visit gets, which includes who is asking
1511
+ CALLER_USERNAME = undefined
1512
+ seedIdentity(IDENTITY_STATE.shipped)
1513
+ return R.reset()
1514
+ })
1515
+ .then(async () => {
1516
+ await persistChanges()
1517
+ reply({ type: 'sasjs-reset-ack', boot: R.bootInfo() })
1518
+ })
1519
+ .catch((e) =>
1520
+ // a reset against a site whose shipped fixture cannot be read has to
1521
+ // say so: the page waits for this ack, and silence reads as success
1522
+ reply({
1523
+ type: 'sasjs-reset-ack',
1524
+ error: (e && e.message) || String(e)
1525
+ })
1526
+ )
1262
1527
  )
1263
1528
  break
1264
1529
 
1530
+ /**
1531
+ * Signs in as another account, for a reviewer who wants to see the app the
1532
+ * way that user sees it.
1533
+ *
1534
+ * The token pair is real to this host - the store issues it and answers
1535
+ * `GET /SASjsApi/session` with it - but it is not a credential in any other
1536
+ * sense: nothing signs it and no password is checked. `undefined` (or the
1537
+ * literal `admin`) means the fixture's administrator. An unknown or inactive
1538
+ * account is refused by name, because a silent fallback to the admin is the
1539
+ * one answer a reviewer would read as "it worked".
1540
+ */
1541
+ case 'sasjs-as': {
1542
+ event.waitUntil(
1543
+ (async () => {
1544
+ try {
1545
+ await ensureIdentity()
1546
+ const fixture = IDENTITY_STATE.store.snapshot()
1547
+ const admin = adminUser()
1548
+
1549
+ const wanted =
1550
+ data.username === undefined || data.username === 'admin'
1551
+ ? admin && admin.username
1552
+ : String(data.username)
1553
+
1554
+ if (!wanted) {
1555
+ reply({
1556
+ type: 'sasjs-as-ack',
1557
+ error:
1558
+ 'The identity fixture declares no administrator, so there is no admin to return to.'
1559
+ })
1560
+ return
1561
+ }
1562
+
1563
+ const user = await IDENTITY_STORE.userByName(wanted)
1564
+
1565
+ if (!user) {
1566
+ reply({
1567
+ type: 'sasjs-as-ack',
1568
+ error: `The identity fixture has no account named "${String(
1569
+ data.username
1570
+ )}". It holds: ${fixture.users
1571
+ .map((candidate) => candidate.username)
1572
+ .join(', ')}.`
1573
+ })
1574
+ return
1575
+ }
1576
+
1577
+ if (!user.isActive) {
1578
+ reply({
1579
+ type: 'sasjs-as-ack',
1580
+ error: `The account "${user.username}" is not active, so nothing can sign in as it.`
1581
+ })
1582
+ return
1583
+ }
1584
+
1585
+ const client = firstClient()
1586
+
1587
+ if (!client) {
1588
+ reply({
1589
+ type: 'sasjs-as-ack',
1590
+ error:
1591
+ 'The identity fixture declares no client, so no token can be issued.'
1592
+ })
1593
+ return
1594
+ }
1595
+
1596
+ const subject = { userId: user.uid, clientId: client.clientId }
1597
+ const pair = await IDENTITY_STORE.issue(subject, client)
1598
+ await IDENTITY_STORE.savePair(subject, pair)
1599
+
1600
+ CALLER_USERNAME = user.username
1601
+ resolveCaller()
1602
+
1603
+ reply({
1604
+ type: 'sasjs-as-ack',
1605
+ username: user.username,
1606
+ accessToken: pair.accessToken,
1607
+ refreshToken: pair.refreshToken,
1608
+ identity: IDENTITY_STATE.store.snapshot()
1609
+ })
1610
+ } catch (e) {
1611
+ reply({
1612
+ type: 'sasjs-as-ack',
1613
+ error: (e && e.message) || String(e)
1614
+ })
1615
+ }
1616
+ })()
1617
+ )
1618
+ break
1619
+ }
1620
+
1265
1621
  case 'sasjs-snapshot':
1266
1622
  reply({ type: 'sasjs-snapshot', files: R.dumpAll() })
1267
1623
  break
@@ -11,6 +11,11 @@ export interface MockSiteResult {
11
11
  snapshot: boolean;
12
12
  /** True when a recorded SAS session was shipped, for .sas runs to answer. */
13
13
  sasTranscript: boolean;
14
+ /**
15
+ * Which identity fixture the site signs in with: the package's demo fixture,
16
+ * or `mock-identity.json` from the project.
17
+ */
18
+ identitySource: 'default' | 'mock-identity.json';
14
19
  }
15
20
  /**
16
21
  * The webout() block is the one @sasjs/utils generates for the server, emitted