@sasjs/cli 6.24.2 → 6.26.0
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/build/commands/build/build.js +6 -0
- package/build/commands/build/internal/appManifest.d.ts +42 -0
- package/build/commands/build/internal/appManifest.js +85 -0
- package/build/commands/build/internal/mockSite/assets/persist.js +165 -26
- package/build/commands/build/internal/mockSite/assets/sw.js +419 -63
- package/build/commands/build/internal/mockSite/mockSite.d.ts +5 -0
- package/build/commands/build/internal/mockSite/mockSite.js +49 -6
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +150 -0
- package/build/commands/build/internal/mockSite/spec/persist.spec.js +335 -15
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +522 -18
- package/build/commands/build/internal/spec/appManifest.spec.d.ts +1 -0
- package/build/commands/build/internal/spec/appManifest.spec.js +71 -0
- package/build/commands/build/spec/build.spec.js +52 -0
- package/build/commands/deploy/internal/appStreamUrl.d.ts +25 -0
- package/build/commands/deploy/internal/appStreamUrl.js +27 -0
- package/build/commands/deploy/internal/deployToSASJSWithServicePack.js +17 -16
- package/build/commands/deploy/internal/executeDeployScriptSasjs.js +14 -13
- package/build/commands/deploy/internal/spec/appStreamUrl.spec.d.ts +1 -0
- package/build/commands/deploy/internal/spec/appStreamUrl.spec.js +42 -0
- package/build/commands/deploy/internal/spec/executeDeployScriptSasjs.spec.js +1 -1
- package/package.json +5 -5
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
* request that carries no credential is
|
|
19
|
-
* visitor, on a host with no login wall.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
//
|
|
886
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|