@things-factory/auth-base 10.1.213 → 10.1.218
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/config/config.development.js +2 -4
- package/config/config.production.js +1 -2
- package/dist-client/actions/auth.d.ts +1 -0
- package/dist-client/actions/auth.js +6 -0
- package/dist-client/actions/auth.js.map +1 -1
- package/dist-client/reducers/auth.d.ts +8 -0
- package/dist-client/reducers/auth.js +8 -2
- package/dist-client/reducers/auth.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/constants/error-code.d.ts +1 -0
- package/dist-server/constants/error-code.js +2 -1
- package/dist-server/constants/error-code.js.map +1 -1
- package/dist-server/controllers/auth.d.ts +0 -1
- package/dist-server/controllers/auth.js +0 -1
- package/dist-server/controllers/auth.js.map +1 -1
- package/dist-server/controllers/password-setup.d.ts +33 -0
- package/dist-server/controllers/password-setup.js +77 -0
- package/dist-server/controllers/password-setup.js.map +1 -0
- package/dist-server/controllers/reset-password.d.ts +8 -1
- package/dist-server/controllers/reset-password.js +21 -11
- package/dist-server/controllers/reset-password.js.map +1 -1
- package/dist-server/controllers/signin.js +10 -0
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +2 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/middlewares/jwt-authenticate-middleware.js +8 -25
- package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +3 -41
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/access-request/access-request.js +2 -2
- package/dist-server/service/access-request/access-request.js.map +1 -1
- package/dist-server/service/audit/audit-kinds.js +1 -0
- package/dist-server/service/audit/audit-kinds.js.map +1 -1
- package/dist-server/service/audit/audit-subscriber.d.ts +1 -7
- package/dist-server/service/audit/audit-subscriber.js +2 -33
- package/dist-server/service/audit/audit-subscriber.js.map +1 -1
- package/dist-server/service/audit/index.d.ts +1 -1
- package/dist-server/service/index.d.ts +2 -4
- package/dist-server/service/index.js +22 -27
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.js +0 -2
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/user/user-mutation.d.ts +27 -6
- package/dist-server/service/user/user-mutation.js +32 -114
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/service/user/user-query.d.ts +3 -2
- package/dist-server/service/user/user-query.js +17 -21
- package/dist-server/service/user/user-query.js.map +1 -1
- package/dist-server/service/user/user-types.d.ts +6 -2
- package/dist-server/service/user/user-types.js +20 -9
- package/dist-server/service/user/user-types.js.map +1 -1
- package/dist-server/service/user/user.js +17 -8
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/service/verification-token/verification-token.d.ts +1 -0
- package/dist-server/service/verification-token/verification-token.js +1 -0
- package/dist-server/service/verification-token/verification-token.js.map +1 -1
- package/dist-server/templates/reset-password-email.d.ts +10 -3
- package/dist-server/templates/reset-password-email.js +8 -4
- package/dist-server/templates/reset-password-email.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/password-reset-rule.d.ts +15 -0
- package/dist-server/utils/password-reset-rule.js +34 -0
- package/dist-server/utils/password-reset-rule.js.map +1 -0
- package/dist-server/utils/verification-token-rule.js +3 -1
- package/dist-server/utils/verification-token-rule.js.map +1 -1
- package/package.json +2 -2
- package/tests/add-user-gates.test.ts +34 -0
- package/tests/list-inheritance-db.test.ts +0 -15
- package/tests/lock-recovery.test.ts +2 -2
- package/tests/open-doors.test.ts +2 -5
- package/tests/password-reset-rule-db.test.ts +159 -0
- package/tests/password-setup-db.test.ts +213 -0
- package/tests/picture-reducer.test.ts +25 -0
- package/tests/verification-token-db.test.ts +12 -7
- package/translations/en.json +4 -2
- package/translations/es.json +4 -2
- package/translations/ja.json +4 -2
- package/translations/ko.json +4 -2
- package/translations/ms.json +4 -2
- package/translations/zh.json +4 -2
- package/dist-server/controllers/invitation.d.ts +0 -75
- package/dist-server/controllers/invitation.js +0 -195
- package/dist-server/controllers/invitation.js.map +0 -1
- package/dist-server/controllers/utils/make-invitation-token.d.ts +0 -1
- package/dist-server/controllers/utils/make-invitation-token.js +0 -9
- package/dist-server/controllers/utils/make-invitation-token.js.map +0 -1
- package/dist-server/controllers/utils/save-invitation-token.d.ts +0 -5
- package/dist-server/controllers/utils/save-invitation-token.js +0 -12
- package/dist-server/controllers/utils/save-invitation-token.js.map +0 -1
- package/dist-server/service/invitation/index.d.ts +0 -5
- package/dist-server/service/invitation/index.js +0 -9
- package/dist-server/service/invitation/index.js.map +0 -1
- package/dist-server/service/invitation/invitation-mutation.d.ts +0 -20
- package/dist-server/service/invitation/invitation-mutation.js +0 -109
- package/dist-server/service/invitation/invitation-mutation.js.map +0 -1
- package/dist-server/service/invitation/invitation-query.d.ts +0 -23
- package/dist-server/service/invitation/invitation-query.js +0 -89
- package/dist-server/service/invitation/invitation-query.js.map +0 -1
- package/dist-server/service/invitation/invitation-types.d.ts +0 -5
- package/dist-server/service/invitation/invitation-types.js +0 -21
- package/dist-server/service/invitation/invitation-types.js.map +0 -1
- package/dist-server/service/invitation/invitation.d.ts +0 -26
- package/dist-server/service/invitation/invitation.js +0 -142
- package/dist-server/service/invitation/invitation.js.map +0 -1
- package/dist-server/templates/invitation-email.d.ts +0 -6
- package/dist-server/templates/invitation-email.js +0 -82
- package/dist-server/templates/invitation-email.js.map +0 -1
- package/dist-server/utils/invitation-state.d.ts +0 -52
- package/dist-server/utils/invitation-state.js +0 -76
- package/dist-server/utils/invitation-state.js.map +0 -1
- package/tests/invitation-db.test.ts +0 -416
- package/tests/invitation-route.test.ts +0 -116
- package/tests/invitation-state.test.ts +0 -92
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* What state an invitation is in, and whether it can still be accepted. (Pure rule.)
|
|
4
|
-
*
|
|
5
|
-
* ## Why the table keeps rows now
|
|
6
|
-
*
|
|
7
|
-
* Accepting used to delete the row and cancelling still does, so the table was a queue of
|
|
8
|
-
* pending invitations and nothing else. The moment somebody joined, the fact that they had been
|
|
9
|
-
* invited stopped existing — which is why no screen was ever built on it, and why nothing calls
|
|
10
|
-
* any of it. An invitation list has to be able to say *who was invited, by whom, when, and
|
|
11
|
-
* whether they came in*.
|
|
12
|
-
*
|
|
13
|
-
* So the row survives and carries its state. `pending` → one of the other three, and never back.
|
|
14
|
-
*
|
|
15
|
-
* ## Why expiry is derived and not only stored
|
|
16
|
-
*
|
|
17
|
-
* A token that outlives its invitation is a credential nobody is watching. If `expired` were
|
|
18
|
-
* only ever a written value, it would depend on something running to write it, and the gap
|
|
19
|
-
* between the expiry passing and that thing running is a window where the token still works.
|
|
20
|
-
* So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the
|
|
21
|
-
* column says. Writing the status is then bookkeeping for the list, not the guard.
|
|
22
|
-
*
|
|
23
|
-
* ## What an absent value means, and why it refuses
|
|
24
|
-
*
|
|
25
|
-
* Rows written before this existed have neither status nor expiry. They come from a flow that
|
|
26
|
-
* never completed — the link in the email led nowhere — so treating them as live would keep
|
|
27
|
-
* tokens alive that were never usable in the first place.
|
|
28
|
-
*
|
|
29
|
-
* status absent reads as `pending`, which is what the old table meant by a row existing
|
|
30
|
-
* expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not
|
|
31
|
-
* a live invitation, and this is a credential: absence refuses.
|
|
32
|
-
*/
|
|
33
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
-
exports.InvitationStatus = void 0;
|
|
35
|
-
exports.invitationState = invitationState;
|
|
36
|
-
exports.mayAccept = mayAccept;
|
|
37
|
-
/** The states an invitation row can be in. Stored, and also derived for expiry. */
|
|
38
|
-
var InvitationStatus;
|
|
39
|
-
(function (InvitationStatus) {
|
|
40
|
-
InvitationStatus["PENDING"] = "pending";
|
|
41
|
-
InvitationStatus["ACCEPTED"] = "accepted";
|
|
42
|
-
InvitationStatus["CANCELLED"] = "cancelled";
|
|
43
|
-
InvitationStatus["EXPIRED"] = "expired";
|
|
44
|
-
})(InvitationStatus || (exports.InvitationStatus = InvitationStatus = {}));
|
|
45
|
-
function expiryPassed(expiresAt, now) {
|
|
46
|
-
if (!expiresAt) {
|
|
47
|
-
/* No expiry recorded — see the note above. Absence refuses. */
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
const at = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
|
|
51
|
-
if (Number.isNaN(at.getTime())) {
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
return at.getTime() <= now.getTime();
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* The state this invitation is in, as of `now`.
|
|
58
|
-
*
|
|
59
|
-
* Only a pending row is measured against the clock. Once a row has been accepted or cancelled
|
|
60
|
-
* that is what happened to it, and time passing does not rewrite history.
|
|
61
|
-
*/
|
|
62
|
-
function invitationState(invitation, now = new Date()) {
|
|
63
|
-
const status = invitation?.status || InvitationStatus.PENDING;
|
|
64
|
-
if (status === InvitationStatus.ACCEPTED || status === InvitationStatus.CANCELLED) {
|
|
65
|
-
return status;
|
|
66
|
-
}
|
|
67
|
-
if (status === InvitationStatus.EXPIRED) {
|
|
68
|
-
return InvitationStatus.EXPIRED;
|
|
69
|
-
}
|
|
70
|
-
return expiryPassed(invitation?.expiresAt, now) ? InvitationStatus.EXPIRED : InvitationStatus.PENDING;
|
|
71
|
-
}
|
|
72
|
-
/** May this invitation still be accepted? Only a pending one that has not run out. */
|
|
73
|
-
function mayAccept(invitation, now = new Date()) {
|
|
74
|
-
return !!invitation && invitationState(invitation, now) === InvitationStatus.PENDING;
|
|
75
|
-
}
|
|
76
|
-
//# sourceMappingURL=invitation-state.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"invitation-state.js","sourceRoot":"","sources":["../../server/utils/invitation-state.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;;;AAqCH,0CAYC;AAGD,8BAEC;AApDD,mFAAmF;AACnF,IAAY,gBAKX;AALD,WAAY,gBAAgB;IAC1B,uCAAmB,CAAA;IACnB,yCAAqB,CAAA;IACrB,2CAAuB,CAAA;IACvB,uCAAmB,CAAA;AACrB,CAAC,EALW,gBAAgB,gCAAhB,gBAAgB,QAK3B;AAQD,SAAS,YAAY,CAAC,SAA2C,EAAE,GAAS;IAC1E,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,+DAA+D;QAC/D,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,GAAG,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,CAAA;IAEtE,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,EAAE,CAAC,OAAO,EAAE,IAAI,GAAG,CAAC,OAAO,EAAE,CAAA;AACtC,CAAC;AAED;;;;;GAKG;AACH,SAAgB,eAAe,CAAC,UAA4B,EAAE,MAAY,IAAI,IAAI,EAAE;IAClF,MAAM,MAAM,GAAG,UAAU,EAAE,MAAM,IAAI,gBAAgB,CAAC,OAAO,CAAA;IAE7D,IAAI,MAAM,KAAK,gBAAgB,CAAC,QAAQ,IAAI,MAAM,KAAK,gBAAgB,CAAC,SAAS,EAAE,CAAC;QAClF,OAAO,MAA0B,CAAA;IACnC,CAAC;IAED,IAAI,MAAM,KAAK,gBAAgB,CAAC,OAAO,EAAE,CAAC;QACxC,OAAO,gBAAgB,CAAC,OAAO,CAAA;IACjC,CAAC;IAED,OAAO,YAAY,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC,gBAAgB,CAAC,OAAO,CAAA;AACvG,CAAC;AAED,sFAAsF;AACtF,SAAgB,SAAS,CAAC,UAA4B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC5E,OAAO,CAAC,CAAC,UAAU,IAAI,eAAe,CAAC,UAAU,EAAE,GAAG,CAAC,KAAK,gBAAgB,CAAC,OAAO,CAAA;AACtF,CAAC","sourcesContent":["/**\n * What state an invitation is in, and whether it can still be accepted. (Pure rule.)\n *\n * ## Why the table keeps rows now\n *\n * Accepting used to delete the row and cancelling still does, so the table was a queue of\n * pending invitations and nothing else. The moment somebody joined, the fact that they had been\n * invited stopped existing — which is why no screen was ever built on it, and why nothing calls\n * any of it. An invitation list has to be able to say *who was invited, by whom, when, and\n * whether they came in*.\n *\n * So the row survives and carries its state. `pending` → one of the other three, and never back.\n *\n * ## Why expiry is derived and not only stored\n *\n * A token that outlives its invitation is a credential nobody is watching. If `expired` were\n * only ever a written value, it would depend on something running to write it, and the gap\n * between the expiry passing and that thing running is a window where the token still works.\n * So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the\n * column says. Writing the status is then bookkeeping for the list, not the guard.\n *\n * ## What an absent value means, and why it refuses\n *\n * Rows written before this existed have neither status nor expiry. They come from a flow that\n * never completed — the link in the email led nowhere — so treating them as live would keep\n * tokens alive that were never usable in the first place.\n *\n * status absent reads as `pending`, which is what the old table meant by a row existing\n * expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not\n * a live invitation, and this is a credential: absence refuses.\n */\n\n/** The states an invitation row can be in. Stored, and also derived for expiry. */\nexport enum InvitationStatus {\n PENDING = 'pending',\n ACCEPTED = 'accepted',\n CANCELLED = 'cancelled',\n EXPIRED = 'expired'\n}\n\n/** The parts of an invitation this rule reads. */\nexport type InvitationRecord = {\n status?: string | null\n expiresAt?: Date | string | null\n} | null | undefined\n\nfunction expiryPassed(expiresAt: Date | string | null | undefined, now: Date): boolean {\n if (!expiresAt) {\n /* No expiry recorded — see the note above. Absence refuses. */\n return true\n }\n\n const at = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return true\n }\n\n return at.getTime() <= now.getTime()\n}\n\n/**\n * The state this invitation is in, as of `now`.\n *\n * Only a pending row is measured against the clock. Once a row has been accepted or cancelled\n * that is what happened to it, and time passing does not rewrite history.\n */\nexport function invitationState(invitation: InvitationRecord, now: Date = new Date()): InvitationStatus {\n const status = invitation?.status || InvitationStatus.PENDING\n\n if (status === InvitationStatus.ACCEPTED || status === InvitationStatus.CANCELLED) {\n return status as InvitationStatus\n }\n\n if (status === InvitationStatus.EXPIRED) {\n return InvitationStatus.EXPIRED\n }\n\n return expiryPassed(invitation?.expiresAt, now) ? InvitationStatus.EXPIRED : InvitationStatus.PENDING\n}\n\n/** May this invitation still be accepted? Only a pending one that has not run out. */\nexport function mayAccept(invitation: InvitationRecord, now: Date = new Date()): boolean {\n return !!invitation && invitationState(invitation, now) === InvitationStatus.PENDING\n}\n"]}
|
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
2
|
-
import { loadCompiled } from './compiled'
|
|
3
|
-
|
|
4
|
-
const { Invitation } = loadCompiled('service/invitation/invitation')
|
|
5
|
-
const { InvitationMutation } = loadCompiled('service/invitation/invitation-mutation')
|
|
6
|
-
const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
|
|
7
|
-
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
8
|
-
const { Role } = loadCompiled('service/role/role')
|
|
9
|
-
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
10
|
-
const { acceptInvitation } = loadCompiled('controllers/invitation')
|
|
11
|
-
const { InvitationStatus } = loadCompiled('utils/invitation-state')
|
|
12
|
-
|
|
13
|
-
const mutation = new InvitationMutation()
|
|
14
|
-
const query = new InvitationQuery()
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Inviting somebody into a domain, and their accepting it.
|
|
18
|
-
*
|
|
19
|
-
* ── What this replaced ──────────────────────────────────────────────────────
|
|
20
|
-
* `invitation-surface-db.test.ts` stood here and pinned the old shape: a row with no domain, no
|
|
21
|
-
* status and no expiry, read by `{ email, reference, type }` from any tenant, deleted the moment
|
|
22
|
-
* anybody accepted. It existed so that nobody could build on `reference` or remove it quietly.
|
|
23
|
-
* It failed the moment this was built, which is what it was for.
|
|
24
|
-
*
|
|
25
|
-
* ── The token ───────────────────────────────────────────────────────────────
|
|
26
|
-
* It travels in the link's fragment, so the server never sees it in a request line. Accepting
|
|
27
|
-
* asks for the invited address too, because a forwarded mail is the one way the token can still
|
|
28
|
-
* reach somebody else and no amount of hiding closes that.
|
|
29
|
-
*
|
|
30
|
-
* ⚠ sqlite (`tests/db.ts`). Mail is not sent here — `@things-factory/email-base` is stubbed by
|
|
31
|
-
* the harness the same way the other mailing paths are, and what is asserted is the row.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
let ours: any
|
|
35
|
-
let theirs: any
|
|
36
|
-
let admin: any
|
|
37
|
-
|
|
38
|
-
function context(d: any) {
|
|
39
|
-
return {
|
|
40
|
-
state: { domain: d, user: admin, tx: undefined },
|
|
41
|
-
lng: 'en',
|
|
42
|
-
headers: {},
|
|
43
|
-
request: { protocol: 'http', host: 'acme.test' },
|
|
44
|
-
throw: (code: number, message: string) => {
|
|
45
|
-
const error: any = new Error(message)
|
|
46
|
-
error.status = code
|
|
47
|
-
throw error
|
|
48
|
-
}
|
|
49
|
-
} as any
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function refusalCodeOf(promise: Promise<unknown>): Promise<string> {
|
|
53
|
-
try {
|
|
54
|
-
await promise
|
|
55
|
-
} catch (error: any) {
|
|
56
|
-
return error?.code || error?.extensions?.refusal?.code || `not a refusal: ${error?.message}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return 'nothing was thrown'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function roleIn(domain: any, name: string) {
|
|
63
|
-
const privilege = await getRepository(Privilege).save({ name: 'query', category: `board-${name}` })
|
|
64
|
-
|
|
65
|
-
return await getRepository(Role).save({ name, domain: { id: domain.id }, privileges: [privilege] })
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function personWithAccount(email: string, domains: any[] = []) {
|
|
69
|
-
return await getRepository(User).save({
|
|
70
|
-
username: email,
|
|
71
|
-
email,
|
|
72
|
-
name: email,
|
|
73
|
-
userType: 'user',
|
|
74
|
-
status: UserStatus.ACTIVATED,
|
|
75
|
-
domains: domains.map(d => ({ id: d.id }))
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** The row as it stands, with its token — which no client is ever given. */
|
|
80
|
-
async function stored(id: string) {
|
|
81
|
-
return await getRepository(Invitation).findOne({ where: { id }, relations: ['domain', 'roles'] })
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
beforeAll(async () => {
|
|
85
|
-
await openAuthDatabase()
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
afterAll(async () => {
|
|
89
|
-
await closeAuthDatabase()
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
beforeEach(async () => {
|
|
93
|
-
await resetAuthDatabase()
|
|
94
|
-
|
|
95
|
-
ours = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
96
|
-
theirs = await getRepository(Domain).save({ name: 'beta', subdomain: 'beta' })
|
|
97
|
-
admin = await personWithAccount('admin@acme.z', [ours])
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
describe('sending one', () => {
|
|
101
|
-
it('stands a pending invitation to this domain, with an expiry and a token', async () => {
|
|
102
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
103
|
-
const row = await stored(sent.id)
|
|
104
|
-
|
|
105
|
-
expect(row.status).toEqual(InvitationStatus.PENDING)
|
|
106
|
-
expect(row.domain.id).toEqual(ours.id)
|
|
107
|
-
expect(row.expiresAt).toBeTruthy()
|
|
108
|
-
expect(row.token).toHaveLength(32)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('takes the domain from the request, not from the caller', async () => {
|
|
112
|
-
/*
|
|
113
|
-
* ⚠ It used to be `reference`, a string the caller passed. That is how a door ends up
|
|
114
|
-
* writing and reading other tenants' rows.
|
|
115
|
-
*/
|
|
116
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
117
|
-
|
|
118
|
-
expect((await stored(sent.id)).domain.id).toEqual(ours.id)
|
|
119
|
-
expect((await stored(sent.id)).domain.id).not.toEqual(theirs.id)
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('carries the roles the person arrives with', async () => {
|
|
123
|
-
/* Without them they sign in and have no screens at all. */
|
|
124
|
-
const viewer = await roleIn(ours, 'viewer')
|
|
125
|
-
|
|
126
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours), [viewer.id])
|
|
127
|
-
|
|
128
|
-
expect((await stored(sent.id)).roles.map((role: any) => role.id)).toEqual([viewer.id])
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it('does not take a role from another domain', async () => {
|
|
132
|
-
const foreign = await roleIn(theirs, 'viewer')
|
|
133
|
-
|
|
134
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours), [foreign.id])
|
|
135
|
-
|
|
136
|
-
expect((await stored(sent.id)).roles).toEqual([])
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('re-issues the standing invitation rather than making a second one', async () => {
|
|
140
|
-
/*
|
|
141
|
-
* ⚠ One pending invitation per person and domain. The history keeps every row, so this
|
|
142
|
-
* cannot be a unique index without forbidding "invited, left, invited again" — it is held
|
|
143
|
-
* here instead.
|
|
144
|
-
*/
|
|
145
|
-
const first = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
146
|
-
const again = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
147
|
-
|
|
148
|
-
expect(again.id).toEqual(first.id)
|
|
149
|
-
expect(await getRepository(Invitation).count()).toEqual(1)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('gives the re-issued one a new token', async () => {
|
|
153
|
-
/* Two live links for one invitation is one more than anybody meant to send. */
|
|
154
|
-
const first = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
155
|
-
const before = (await stored(first.id)).token
|
|
156
|
-
|
|
157
|
-
await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
158
|
-
|
|
159
|
-
expect((await stored(first.id)).token).not.toEqual(before)
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it('refuses somebody who is already here', async () => {
|
|
163
|
-
await personWithAccount('kim@acme.z', [ours])
|
|
164
|
-
|
|
165
|
-
expect(await refusalCodeOf(mutation.sendInvitation('kim@acme.z', context(ours)))).toEqual(
|
|
166
|
-
'invitation-already-a-member'
|
|
167
|
-
)
|
|
168
|
-
})
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
describe('accepting', () => {
|
|
172
|
-
async function pending(email = 'kim@acme.z', roleNames: string[] = []) {
|
|
173
|
-
const roles = []
|
|
174
|
-
|
|
175
|
-
for (const name of roleNames) {
|
|
176
|
-
roles.push(await roleIn(ours, name))
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const sent = await mutation.sendInvitation(
|
|
180
|
-
email,
|
|
181
|
-
context(ours),
|
|
182
|
-
roles.map(role => role.id)
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
return await stored(sent.id)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
it('lets somebody who already has an account in, with their roles', async () => {
|
|
189
|
-
const invitation = await pending('kim@acme.z', ['viewer'])
|
|
190
|
-
const kim = await personWithAccount('kim@acme.z')
|
|
191
|
-
|
|
192
|
-
const { created } = await acceptInvitation({
|
|
193
|
-
token: invitation.token,
|
|
194
|
-
email: 'kim@acme.z',
|
|
195
|
-
context: context(ours)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
const joined = await getRepository(User).findOne({ where: { id: kim.id }, relations: ['domains', 'roles'] })
|
|
199
|
-
|
|
200
|
-
expect(created).toBe(false)
|
|
201
|
-
expect(joined.domains.map((d: any) => d.id)).toContain(ours.id)
|
|
202
|
-
expect(joined.roles.map((role: any) => role.name)).toEqual(['viewer'])
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
it('creates the account for somebody who has none', async () => {
|
|
206
|
-
/*
|
|
207
|
-
* ⚠ The case this whole feature is for. The `user-invite` screen records that an installation
|
|
208
|
-
* with no default password has no path at all for a person who does not exist yet — nobody
|
|
209
|
-
* can set their password for them.
|
|
210
|
-
*/
|
|
211
|
-
const invitation = await pending('new@acme.z', ['viewer'])
|
|
212
|
-
|
|
213
|
-
const { created } = await acceptInvitation({
|
|
214
|
-
token: invitation.token,
|
|
215
|
-
email: 'new@acme.z',
|
|
216
|
-
name: 'New Person',
|
|
217
|
-
password: 'Passw0rd!x',
|
|
218
|
-
context: context(ours)
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
const person = await getRepository(User).findOne({
|
|
222
|
-
where: { email: 'new@acme.z' },
|
|
223
|
-
relations: ['domains', 'roles']
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
expect(created).toBe(true)
|
|
227
|
-
expect(person.status).toEqual(UserStatus.ACTIVATED)
|
|
228
|
-
expect(person.domains.map((d: any) => d.id)).toEqual([ours.id])
|
|
229
|
-
expect(person.roles.map((role: any) => role.name)).toEqual(['viewer'])
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
it('will not create an account without a password', async () => {
|
|
233
|
-
const invitation = await pending('new@acme.z')
|
|
234
|
-
|
|
235
|
-
expect(
|
|
236
|
-
await refusalCodeOf(
|
|
237
|
-
acceptInvitation({ token: invitation.token, email: 'new@acme.z', context: context(ours) })
|
|
238
|
-
)
|
|
239
|
-
).toEqual('invitation-needs-a-password')
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
it('marks the row accepted instead of deleting it', async () => {
|
|
243
|
-
/* ⚠ The reason the table exists. Deleting takes the fact that they were invited with it. */
|
|
244
|
-
const invitation = await pending()
|
|
245
|
-
await personWithAccount('kim@acme.z')
|
|
246
|
-
|
|
247
|
-
await acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
|
|
248
|
-
|
|
249
|
-
const row = await stored(invitation.id)
|
|
250
|
-
|
|
251
|
-
expect(row).not.toBeNull()
|
|
252
|
-
expect(row.status).toEqual(InvitationStatus.ACCEPTED)
|
|
253
|
-
expect(row.acceptedAt).toBeTruthy()
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
it('cannot be used twice', async () => {
|
|
257
|
-
const invitation = await pending()
|
|
258
|
-
await personWithAccount('kim@acme.z')
|
|
259
|
-
|
|
260
|
-
await acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
|
|
261
|
-
|
|
262
|
-
expect(
|
|
263
|
-
await refusalCodeOf(
|
|
264
|
-
acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
|
|
265
|
-
)
|
|
266
|
-
).toEqual('invitation-not-open')
|
|
267
|
-
})
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
describe('⚠ what the token alone is not enough for', () => {
|
|
271
|
-
async function pending(email = 'kim@acme.z') {
|
|
272
|
-
return await stored((await mutation.sendInvitation(email, context(ours))).id)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
it('refuses when the address given is not the one invited', async () => {
|
|
276
|
-
/*
|
|
277
|
-
* ⚠ The mitigation for the one leak that hiding the token cannot close: the mail being
|
|
278
|
-
* forwarded. Holding the link is not enough on its own.
|
|
279
|
-
*/
|
|
280
|
-
const invitation = await pending('kim@acme.z')
|
|
281
|
-
await personWithAccount('someone.else@acme.z')
|
|
282
|
-
|
|
283
|
-
expect(
|
|
284
|
-
await refusalCodeOf(
|
|
285
|
-
acceptInvitation({ token: invitation.token, email: 'someone.else@acme.z', context: context(ours) })
|
|
286
|
-
)
|
|
287
|
-
).toEqual('invitation-not-open')
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
it('accepts the invited address whatever case it is typed in', async () => {
|
|
291
|
-
const invitation = await pending('Kim@Acme.Z')
|
|
292
|
-
await personWithAccount('kim@acme.z')
|
|
293
|
-
|
|
294
|
-
await expect(
|
|
295
|
-
acceptInvitation({ token: invitation.token, email: ' KIM@acme.z ', context: context(ours) })
|
|
296
|
-
).resolves.toBeTruthy()
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
it('refuses an expired invitation', async () => {
|
|
300
|
-
const invitation = await pending()
|
|
301
|
-
await personWithAccount('kim@acme.z')
|
|
302
|
-
|
|
303
|
-
await getRepository(Invitation).save({ ...invitation, expiresAt: new Date(Date.now() - 1000) })
|
|
304
|
-
|
|
305
|
-
expect(
|
|
306
|
-
await refusalCodeOf(
|
|
307
|
-
acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
|
|
308
|
-
)
|
|
309
|
-
).toEqual('invitation-not-open')
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
it('refuses a cancelled one whose expiry is still ahead', async () => {
|
|
313
|
-
const invitation = await pending()
|
|
314
|
-
await personWithAccount('kim@acme.z')
|
|
315
|
-
|
|
316
|
-
await mutation.cancelInvitation(invitation.id, context(ours))
|
|
317
|
-
|
|
318
|
-
expect(
|
|
319
|
-
await refusalCodeOf(
|
|
320
|
-
acceptInvitation({ token: invitation.token, email: 'kim@acme.z', context: context(ours) })
|
|
321
|
-
)
|
|
322
|
-
).toEqual('invitation-not-open')
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
it('refuses a token that is not one of ours, the same way', async () => {
|
|
326
|
-
/*
|
|
327
|
-
* One answer for a wrong token, a wrong address, an expired one and a cancelled one. Telling
|
|
328
|
-
* them apart would let somebody learn which tokens are real.
|
|
329
|
-
*/
|
|
330
|
-
expect(
|
|
331
|
-
await refusalCodeOf(acceptInvitation({ token: 'no-such-token', email: 'kim@acme.z', context: context(ours) }))
|
|
332
|
-
).toEqual('invitation-not-open')
|
|
333
|
-
})
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
describe('withdrawing one', () => {
|
|
337
|
-
it('marks it cancelled and keeps the row', async () => {
|
|
338
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
339
|
-
|
|
340
|
-
expect(await mutation.cancelInvitation(sent.id, context(ours))).toBe(true)
|
|
341
|
-
expect((await stored(sent.id)).status).toEqual(InvitationStatus.CANCELLED)
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
it('does not reach another tenant', async () => {
|
|
345
|
-
/* ⚠ It used to delete by `{ email, reference, type }`, which took an id from anywhere. */
|
|
346
|
-
const foreign = await getRepository(Invitation).save({
|
|
347
|
-
email: 'lee@beta.z',
|
|
348
|
-
domain: { id: theirs.id },
|
|
349
|
-
status: InvitationStatus.PENDING,
|
|
350
|
-
token: 'theirs',
|
|
351
|
-
expiresAt: new Date(Date.now() + 3600_000)
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
expect(await mutation.cancelInvitation(foreign.id, context(ours))).toBe(false)
|
|
355
|
-
expect((await stored(foreign.id)).status).toEqual(InvitationStatus.PENDING)
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
it('says no for one that is already finished', async () => {
|
|
359
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
360
|
-
await mutation.cancelInvitation(sent.id, context(ours))
|
|
361
|
-
|
|
362
|
-
expect(await mutation.cancelInvitation(sent.id, context(ours))).toBe(false)
|
|
363
|
-
})
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
describe('the list', () => {
|
|
367
|
-
it('shows this domain and not another', async () => {
|
|
368
|
-
await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
369
|
-
await getRepository(Invitation).save({
|
|
370
|
-
email: 'lee@beta.z',
|
|
371
|
-
domain: { id: theirs.id },
|
|
372
|
-
status: InvitationStatus.PENDING,
|
|
373
|
-
token: 'theirs',
|
|
374
|
-
expiresAt: new Date(Date.now() + 3600_000)
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
const { items, total } = await query.invitations({ filters: [] } as any, context(ours))
|
|
378
|
-
|
|
379
|
-
expect(total).toEqual(1)
|
|
380
|
-
expect(items[0].email).toEqual('kim@acme.z')
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
it('keeps accepted invitations in it', async () => {
|
|
384
|
-
/* ⚠ The history. This is what there was nothing to put on a screen before. */
|
|
385
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
386
|
-
await personWithAccount('kim@acme.z')
|
|
387
|
-
|
|
388
|
-
await acceptInvitation({ token: (await stored(sent.id)).token, email: 'kim@acme.z', context: context(ours) })
|
|
389
|
-
|
|
390
|
-
const { items, total } = await query.invitations({ filters: [] } as any, context(ours))
|
|
391
|
-
|
|
392
|
-
expect(total).toEqual(1)
|
|
393
|
-
expect(await query.state(items[0])).toEqual(InvitationStatus.ACCEPTED)
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
it('calls a pending row past its expiry expired, before anything writes that', async () => {
|
|
397
|
-
const sent = await mutation.sendInvitation('kim@acme.z', context(ours))
|
|
398
|
-
const row = await stored(sent.id)
|
|
399
|
-
|
|
400
|
-
await getRepository(Invitation).save({ ...row, expiresAt: new Date(Date.now() - 1000) })
|
|
401
|
-
|
|
402
|
-
expect(await query.state(await stored(sent.id))).toEqual(InvitationStatus.EXPIRED)
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
it('does not answer for an id from another tenant', async () => {
|
|
406
|
-
const foreign = await getRepository(Invitation).save({
|
|
407
|
-
email: 'lee@beta.z',
|
|
408
|
-
domain: { id: theirs.id },
|
|
409
|
-
status: InvitationStatus.PENDING,
|
|
410
|
-
token: 'theirs',
|
|
411
|
-
expiresAt: new Date(Date.now() + 3600_000)
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
expect(await query.invitation(foreign.id, context(ours))).toBeNull()
|
|
415
|
-
})
|
|
416
|
-
})
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync } from 'fs'
|
|
2
|
-
import { join } from 'path'
|
|
3
|
-
|
|
4
|
-
import { loadCompiled } from './compiled'
|
|
5
|
-
|
|
6
|
-
const { authPublicProcessRouter } = loadCompiled('router/auth-public-process-router')
|
|
7
|
-
const { ACCEPT_INVITATION_PATH } = loadCompiled('controllers/invitation')
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The link in each mail lands on a route that exists.
|
|
11
|
-
*
|
|
12
|
-
* ── Why this file exists ────────────────────────────────────────────────────
|
|
13
|
-
* The invitation mail pointed at `/auth/accept/<token>` — a path with no route anywhere — so the
|
|
14
|
-
* invitee fell through to the private history fallback and the domain gate turned them away for
|
|
15
|
-
* not being a member yet. That was found and fixed on 2026-09-20, and the fix made the same
|
|
16
|
-
* mistake again: the new route was declared as `/accept-invitation` on a router whose prefix is
|
|
17
|
-
* `/auth`, and the mail and the page both used `/accept-invitation`. It shipped that way.
|
|
18
|
-
*
|
|
19
|
-
* Every test of the invitation called the controller directly, so none of them ever went through
|
|
20
|
-
* a route. This one reads the router's own table — the paths koa-router resolved, prefix included
|
|
21
|
-
* — and holds each mail's link against it.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
type Layer = { path: string; methods: string[] }
|
|
25
|
-
|
|
26
|
-
const layers: Layer[] = authPublicProcessRouter.stack.map((layer: any) => ({
|
|
27
|
-
path: layer.path,
|
|
28
|
-
methods: layer.methods
|
|
29
|
-
}))
|
|
30
|
-
|
|
31
|
-
function answers(method: string, path: string): boolean {
|
|
32
|
-
return layers.some(layer => layer.methods.includes(method) && matches(layer.path, path))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** A declared path against a concrete one: `:param` matches one segment. */
|
|
36
|
-
function matches(declared: string, actual: string): boolean {
|
|
37
|
-
const a = declared.split('/')
|
|
38
|
-
const b = actual.split('/')
|
|
39
|
-
|
|
40
|
-
return a.length === b.length && a.every((part, i) => part.startsWith(':') || part === b[i])
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('the invitation link', () => {
|
|
44
|
-
it('the page answers where the mail points', () => {
|
|
45
|
-
/* ⚠ The case that shipped broken. */
|
|
46
|
-
expect(answers('GET', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('the page posts where the router listens', () => {
|
|
50
|
-
expect(answers('POST', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('carries the prefix the router adds', () => {
|
|
54
|
-
/*
|
|
55
|
-
* Declaring `/accept-invitation` on this router makes it `/auth/accept-invitation`. The spelling
|
|
56
|
-
* without the prefix is exactly the one that went out, so it is named here as the wrong one.
|
|
57
|
-
*/
|
|
58
|
-
expect(ACCEPT_INVITATION_PATH.startsWith('/auth/')).toBe(true)
|
|
59
|
-
expect(answers('GET', '/accept-invitation')).toBe(false)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('is the path the client page uses too', () => {
|
|
63
|
-
const page = readFileSync(
|
|
64
|
-
join(__dirname, '..', '..', 'auth-ui', 'client', 'components', 'accept-invitation.ts'),
|
|
65
|
-
'utf-8'
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
expect(page).toContain(`return '${ACCEPT_INVITATION_PATH}'`)
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
describe('the other links sent by mail', () => {
|
|
73
|
-
/*
|
|
74
|
-
* Reset, unlock and sign-up confirmation moved to the invitation's shape on 2026-09-24 (Q4): one
|
|
75
|
-
* exported path, the token after `#`. Each page answers GET where the mail points and POST where
|
|
76
|
-
* the page sends the token.
|
|
77
|
-
*/
|
|
78
|
-
const { RESET_PASSWORD_PATH } = loadCompiled('controllers/reset-password')
|
|
79
|
-
const { UNLOCK_USER_PATH } = loadCompiled('controllers/unlock-user')
|
|
80
|
-
const { VERIFY_EMAIL_PATH } = loadCompiled('controllers/verification')
|
|
81
|
-
|
|
82
|
-
const paths = [RESET_PASSWORD_PATH, UNLOCK_USER_PATH, VERIFY_EMAIL_PATH]
|
|
83
|
-
|
|
84
|
-
it('names the three it means to check', () => {
|
|
85
|
-
expect(paths).toEqual(['/auth/reset-password', '/auth/unlock-user', '/auth/verify'])
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it.each(paths)('%s — the page answers where the mail points', path => {
|
|
89
|
-
expect(answers('GET', path)).toBe(true)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it.each(paths)('%s — and listens where the page posts', path => {
|
|
93
|
-
expect(answers('POST', path)).toBe(true)
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('no longer answers the old link that confirmed on opening', () => {
|
|
97
|
-
/* `GET /auth/verify/<token>` confirmed the address for whoever fetched it — a mail scanner too. */
|
|
98
|
-
expect(answers('GET', '/auth/verify/0123456789abcdef0123456789abcdef')).toBe(false)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('no controller puts a token in a query string or a path', () => {
|
|
102
|
-
const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
|
|
103
|
-
const offending = readdirSync(CONTROLLERS)
|
|
104
|
-
.filter(file => file.endsWith('.ts'))
|
|
105
|
-
.filter(file => /\?token=\$\{|\/verify\/\$\{/.test(readFileSync(join(CONTROLLERS, file), 'utf-8')))
|
|
106
|
-
|
|
107
|
-
expect(offending).toEqual([])
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('nor does the redirect for a password that must be reset', () => {
|
|
111
|
-
const middleware = readFileSync(join(__dirname, '..', 'server', 'middlewares', 'jwt-authenticate-middleware.ts'), 'utf-8')
|
|
112
|
-
|
|
113
|
-
expect(middleware).toContain('context.redirect(`${RESET_PASSWORD_PATH}#${token}`)')
|
|
114
|
-
expect(middleware).not.toMatch(/\?token=/)
|
|
115
|
-
})
|
|
116
|
-
})
|