or3-provider-basic-auth 0.0.10 → 0.0.12

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/README.md CHANGED
@@ -69,6 +69,7 @@ Strict-mode behavior:
69
69
  - Canonical OR3 users/workspaces are still resolved by the selected `AuthWorkspaceStore`.
70
70
  - In `invite_only` mode, signed-token, expiry, persisted invite state/token hash, and normalized-email validation runs before Basic Auth creates an account or session.
71
71
  - The selected `AuthWorkspaceStore` must support atomic invite provisioning; internal user/auth mapping, membership, and invite consumption are committed together by that provider.
72
+ - Invite links (`/?invite=TOKEN`, copied from Admin → Workspaces, or carried in the lock page's `next`) open registration for signed-out visitors with the token filled in. The token is removed from the address bar on first read and kept in memory for that page; only one mounted auth control (sidebar, mobile More sheet or lock page) opens registration per link. On mobile the account control lives in More, so registration opens when the visitor opens More. This invite-link handoff requires published `or3-provider-basic-auth@0.0.11` or later.
72
73
  - Basic Auth account and initial refresh-session rows are created in one local SQLite transaction.
73
74
  - Access JWTs are short-lived and validated by `basicAuthProvider.getSession(event)`.
74
75
  - Refresh tokens are rotated and hashed at rest; replay attempts revoke active sessions.
@@ -154,3 +155,11 @@ provider's SQLite driver. If the source launcher selects Bun for sync, this
154
155
  native binding must also work under the project-pinned Bun version. The launcher
155
156
  checks it in that runtime before starting Nuxt and reports incompatible bindings
156
157
  or runtime crashes. Managed Cloud uses Node with `better-sqlite3`.
158
+
159
+ ## Instance signup invitations
160
+
161
+ The updated host distinguishes signup invites from existing team invites. Signup invites create a private workspace with only the recipient as owner; team invites retain their selected workspace and role. This requires the updated host and rebuilt provider artifacts, rather than the existing published release alone.
162
+
163
+ Registration preflight rejects invalid invites before credential creation, including in open mode. It resolves the explicit body token first, followed by the host's header, HttpOnly cookie, query, and Referer precedence. A valid token is retained in the invite cookie for canonical session resolution; an explicit replacement overrides a stale cookie. Without a token, open registration remains available. If credentials commit before canonical acceptance, use Already registered? Sign in with the same account and a valid or replacement invite. The sign-in request preserves manually pasted tokens, verifies the signed-in email, and uses the existing HttpOnly invite cookie. The selected sync store still owns atomic acceptance; provider-only credentials grant no protected OR3 access. No credential lifecycle or recovery service is added.
164
+
165
+ Provider-only tests use the exact host registration and token sources pinned in `tests/fixtures/host`, with SHA-256 provenance verified by Vitest. No sibling checkout is required. To exercise the current host integration, run `OR3_CANONICAL_HOST_ROOT=/absolute/path/to/or3-chat bun run test`. Refresh the pinned files and provenance together when adopting a reviewed host contract change; do not replace the host validation logic with a provider-local implementation.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "or3-provider-basic-auth",
3
3
  "configKey": "or3-provider-basic-auth",
4
- "version": "0.0.10",
4
+ "version": "0.0.12",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -17,24 +17,33 @@
17
17
 
18
18
  <BasicAuthSignInModal
19
19
  v-model="signInModalOpen"
20
+ :invite-token="inviteToken"
20
21
  @signed-in="handleSignedIn"
21
22
  @open-register="openRegisterFromSignIn"
22
23
  />
23
24
 
24
25
  <BasicAuthRegisterModal
25
26
  v-model="registerModalOpen"
27
+ :invite-token="inviteToken"
28
+ @open-sign-in="openSignInFromRegister"
26
29
  @registered="handleRegistered"
27
30
  />
28
31
  </div>
29
32
  </template>
30
33
 
31
34
  <script setup>
32
- import { nextTick, ref } from "vue";
35
+ import { nextTick, onMounted, ref } from "vue";
33
36
  import BasicAuthRegisterModal from "./BasicAuthRegisterModal.client.vue";
34
37
  import BasicAuthSignInModal from "./BasicAuthSignInModal.client.vue";
38
+ import { claimInviteLinkAutoOpen, readInviteLinkToken } from "../lib/invite-link.client";
35
39
  const loginIcon = "i-lucide-log-in";
36
40
  const signInModalOpen = ref(false);
37
41
  const registerModalOpen = ref(false);
42
+ const inviteToken = ref(null);
43
+ onMounted(() => {
44
+ inviteToken.value = readInviteLinkToken();
45
+ if (claimInviteLinkAutoOpen()) registerModalOpen.value = true;
46
+ });
38
47
  function notifyAuthSessionChanged() {
39
48
  if (typeof window === "undefined") return;
40
49
  window.dispatchEvent(new CustomEvent("or3:auth-session-changed"));
@@ -52,4 +61,10 @@ async function openRegisterFromSignIn() {
52
61
  registerModalOpen.value = true;
53
62
  }, 0);
54
63
  }
64
+ async function openSignInFromRegister(token) {
65
+ inviteToken.value = token || inviteToken.value;
66
+ registerModalOpen.value = false;
67
+ await nextTick();
68
+ signInModalOpen.value = true;
69
+ }
55
70
  </script>
@@ -1,11 +1,15 @@
1
1
  type __VLS_Props = {
2
2
  modelValue: boolean;
3
+ /** Token from an invite link; filled in when the form opens. */
4
+ inviteToken?: string | null;
3
5
  };
4
6
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
7
  "update:modelValue": (value: boolean) => any;
6
8
  registered: () => any;
9
+ "open-sign-in": (inviteToken: string) => any;
7
10
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
11
  "onUpdate:modelValue"?: ((value: boolean) => any) | undefined;
9
12
  onRegistered?: (() => any) | undefined;
13
+ "onOpen-sign-in"?: ((inviteToken: string) => any) | undefined;
10
14
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
11
15
  export default _default;
@@ -73,7 +73,10 @@
73
73
  />
74
74
  </UFormField>
75
75
 
76
- <UFormField label="Invite token (optional)" name="inviteToken">
76
+ <UFormField
77
+ :label="props.inviteToken ? 'Invite token (from your invite link)' : 'Invite token (optional)'"
78
+ name="inviteToken"
79
+ >
77
80
  <UInput
78
81
  v-model="state.inviteToken"
79
82
  type="text"
@@ -90,6 +93,7 @@
90
93
  {{ errorMessage }}
91
94
  </div>
92
95
 
96
+ <UButton type="button" color="neutral" variant="ghost" :disabled="pending" @click="emit('open-sign-in', state.inviteToken)">Already registered? Sign in</UButton>
93
97
  <div class="flex justify-end gap-2 pt-1">
94
98
  <UButton
95
99
  color="neutral"
@@ -111,16 +115,17 @@
111
115
  </template>
112
116
 
113
117
  <script setup>
114
- import { computed, reactive, ref } from "vue";
118
+ import { computed, reactive, ref, watch } from "vue";
115
119
  import { newPasswordFitsBcrypt } from "../password-policy";
116
120
  const modalUi = {
117
121
  overlay: "basic-auth-modal-overlay",
118
122
  content: "basic-auth-modal-content sm:max-w-[420px]"
119
123
  };
120
124
  const props = defineProps({
121
- modelValue: { type: Boolean, required: true }
125
+ modelValue: { type: Boolean, required: true },
126
+ inviteToken: { type: [String, null], required: false }
122
127
  });
123
- const emit = defineEmits(["update:modelValue", "registered"]);
128
+ const emit = defineEmits(["update:modelValue", "registered", "open-sign-in"]);
124
129
  const state = reactive({
125
130
  email: "",
126
131
  displayName: "",
@@ -136,6 +141,9 @@ const isOpen = computed({
136
141
  emit("update:modelValue", value);
137
142
  }
138
143
  });
144
+ watch(() => [props.modelValue, props.inviteToken], ([open, token]) => {
145
+ if (open && token && !state.inviteToken.trim()) state.inviteToken = token;
146
+ }, { immediate: true });
139
147
  function close() {
140
148
  errorMessage.value = "";
141
149
  emit("update:modelValue", false);
@@ -194,7 +202,7 @@ async function onSubmit() {
194
202
  } else if (statusCode === 403) {
195
203
  errorMessage.value = serverMessage || "Registration is restricted for this instance.";
196
204
  } else if (statusCode === 401) {
197
- errorMessage.value = "Email is already in use.";
205
+ errorMessage.value = "Unable to register. If you already registered, sign in to continue.";
198
206
  } else if (statusCode === 409) {
199
207
  errorMessage.value = "Email is already in use.";
200
208
  } else {
@@ -1,11 +1,15 @@
1
1
  type __VLS_Props = {
2
2
  modelValue: boolean;
3
+ /** Token from an invite link; filled in when the form opens. */
4
+ inviteToken?: string | null;
3
5
  };
4
6
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
7
  "update:modelValue": (value: boolean) => any;
6
8
  registered: () => any;
9
+ "open-sign-in": (inviteToken: string) => any;
7
10
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
11
  "onUpdate:modelValue"?: ((value: boolean) => any) | undefined;
9
12
  onRegistered?: (() => any) | undefined;
13
+ "onOpen-sign-in"?: ((inviteToken: string) => any) | undefined;
10
14
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
11
15
  export default _default;
@@ -1,5 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  modelValue: boolean;
3
+ inviteToken?: string;
3
4
  };
4
5
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
6
  "update:modelValue": (value: boolean) => any;
@@ -89,7 +89,8 @@ const modalUi = {
89
89
  content: "basic-auth-modal-content sm:max-w-[380px]"
90
90
  };
91
91
  const props = defineProps({
92
- modelValue: { type: Boolean, required: true }
92
+ modelValue: { type: Boolean, required: true },
93
+ inviteToken: { type: String, required: false }
93
94
  });
94
95
  const emit = defineEmits(["update:modelValue", "signed-in", "open-register"]);
95
96
  const state = reactive({
@@ -116,7 +117,8 @@ async function onSubmit() {
116
117
  method: "POST",
117
118
  body: {
118
119
  email: state.email,
119
- password: state.password
120
+ password: state.password,
121
+ inviteToken: props.inviteToken?.trim() || void 0
120
122
  }
121
123
  });
122
124
  emit("signed-in");
@@ -1,5 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  modelValue: boolean;
3
+ inviteToken?: string;
3
4
  };
4
5
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
6
  "update:modelValue": (value: boolean) => any;
@@ -51,12 +51,15 @@
51
51
 
52
52
  <BasicAuthSignInModal
53
53
  v-model="signInModalOpen"
54
+ :invite-token="inviteToken"
54
55
  @signed-in="onSignedIn"
55
56
  @open-register="openRegisterFromSignIn"
56
57
  />
57
58
 
58
59
  <BasicAuthRegisterModal
59
60
  v-model="registerModalOpen"
61
+ :invite-token="inviteToken"
62
+ @open-sign-in="openSignInFromRegister"
60
63
  @registered="onRegistered"
61
64
  />
62
65
 
@@ -76,6 +79,7 @@ import {
76
79
  AUTH_UI_LAYOUT_KEY
77
80
  } from "../lib/auth-ui-layout";
78
81
  import { silentRefreshOnce } from "../lib/silent-refresh.client";
82
+ import { claimInviteLinkAutoOpen, readInviteLinkToken } from "../lib/invite-link.client";
79
83
  import BasicAuthSignInModal from "./BasicAuthSignInModal.client.vue";
80
84
  import BasicAuthRegisterModal from "./BasicAuthRegisterModal.client.vue";
81
85
  import BasicAuthUserMenu from "./BasicAuthUserMenu.client.vue";
@@ -171,9 +175,20 @@ async function onRegistered() {
171
175
  await refreshSession({ allowSilentRefresh: false });
172
176
  notifyAuthSessionChanged();
173
177
  }
174
- onMounted(() => {
175
- void refreshSession();
178
+ const inviteToken = ref(null);
179
+ onMounted(async () => {
180
+ inviteToken.value = readInviteLinkToken();
181
+ await refreshSession();
182
+ if (!isSignedIn.value && isBasicAuthProvider.value && claimInviteLinkAutoOpen()) {
183
+ registerModalOpen.value = true;
184
+ }
176
185
  });
186
+ async function openSignInFromRegister(token) {
187
+ inviteToken.value = token || inviteToken.value;
188
+ registerModalOpen.value = false;
189
+ await nextTick();
190
+ signInModalOpen.value = true;
191
+ }
177
192
  </script>
178
193
 
179
194
  <style>
@@ -0,0 +1,4 @@
1
+ export declare function readInviteLinkToken(): string | null;
2
+ /** True for the one caller that should open registration for this link. */
3
+ export declare function claimInviteLinkAutoOpen(): boolean;
4
+ export declare function resetInviteLinkForTests(): void;
@@ -0,0 +1,39 @@
1
+ let token;
2
+ let autoOpenClaimed = false;
3
+ function takeInvite(url) {
4
+ const direct = url.searchParams.get("invite")?.trim();
5
+ if (direct) {
6
+ url.searchParams.delete("invite");
7
+ return direct;
8
+ }
9
+ const next = url.searchParams.get("next");
10
+ if (!next) return null;
11
+ const target = new URL(next, url.origin);
12
+ const carried = target.searchParams.get("invite")?.trim();
13
+ if (!carried) return null;
14
+ target.searchParams.delete("invite");
15
+ url.searchParams.set("next", target.pathname + target.search + target.hash);
16
+ return carried;
17
+ }
18
+ export function readInviteLinkToken() {
19
+ if (token !== void 0) return token;
20
+ token = null;
21
+ if (typeof window === "undefined") return token;
22
+ try {
23
+ const url = new URL(window.location.href);
24
+ token = takeInvite(url);
25
+ if (token) window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
26
+ } catch {
27
+ token = null;
28
+ }
29
+ return token;
30
+ }
31
+ export function claimInviteLinkAutoOpen() {
32
+ if (!readInviteLinkToken() || autoOpenClaimed) return false;
33
+ autoOpenClaimed = true;
34
+ return true;
35
+ }
36
+ export function resetInviteLinkForTests() {
37
+ token = void 0;
38
+ autoOpenClaimed = false;
39
+ }
@@ -4,6 +4,7 @@ import { createError, defineEventHandler, setCookie } from "h3";
4
4
  import { z } from "zod";
5
5
  import { getAuthWorkspaceStore } from "~~/server/auth/store/registry";
6
6
  import {
7
+ getInviteTokenFromEvent,
7
8
  resolveRegistrationMode,
8
9
  validateInviteRegistration
9
10
  } from "~~/server/auth/registration";
@@ -68,10 +69,11 @@ export async function handleRegister(event) {
68
69
  if (mode === "disabled") {
69
70
  mapRegistrationErrorToHttp("disabled");
70
71
  }
71
- if (mode === "invite_only" && !body.inviteToken?.trim()) {
72
+ const inviteToken = body.inviteToken ?? getInviteTokenFromEvent(event);
73
+ if (mode === "invite_only" && !inviteToken) {
72
74
  mapRegistrationErrorToHttp("invite_required");
73
75
  }
74
- if (mode === "invite_only") {
76
+ if (inviteToken) {
75
77
  const syncConfig = runtimeConfig;
76
78
  const storeId = syncConfig.sync?.provider ?? syncConfig.public?.sync?.provider ?? "convex";
77
79
  const store = getAuthWorkspaceStore(storeId);
@@ -83,7 +85,7 @@ export async function handleRegister(event) {
83
85
  store,
84
86
  mode,
85
87
  email,
86
- inviteToken: body.inviteToken
88
+ inviteToken
87
89
  });
88
90
  if (!decision.allowed) {
89
91
  mapRegistrationErrorToHttp(decision.reason);
@@ -129,8 +131,8 @@ export async function handleRegister(event) {
129
131
  });
130
132
  setAccessCookie(event, accessToken, config.accessTtlSeconds);
131
133
  setRefreshCookie(event, refreshToken, config.refreshTtlSeconds);
132
- if (mode === "invite_only" && body.inviteToken?.trim()) {
133
- setCookie(event, "or3_invite_token", body.inviteToken.trim(), {
134
+ if (inviteToken) {
135
+ setCookie(event, "or3_invite_token", inviteToken, {
134
136
  httpOnly: true,
135
137
  sameSite: "lax",
136
138
  secure: process.env.NODE_ENV === "production",
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { defineEventHandler } from "h3";
2
+ import { defineEventHandler, setCookie } from "h3";
3
+ import { useRuntimeConfig } from "#imports";
4
+ import { verifyInviteToken } from "~~/server/auth/invite-token";
3
5
  import { z } from "zod";
4
6
  import { clearAuthCookies, setAccessCookie, setRefreshCookie } from "../../lib/cookies.js";
5
7
  import { getBasicAuthConfig } from "../../lib/config.js";
@@ -21,7 +23,8 @@ import { assertBasicAuthReady, noStore, parseBodyWithSchema } from "./_helpers.j
21
23
  const signInSchema = z.object({
22
24
  email: z.string().email().max(320),
23
25
  // Sign-in must allow legacy short passwords; strength is enforced at set/change time.
24
- password: z.string().min(1).max(512)
26
+ password: z.string().min(1).max(512),
27
+ inviteToken: z.string().trim().min(1).max(4096).optional()
25
28
  });
26
29
  const DUMMY_PASSWORD_HASH = "$2a$12$1XCXpgmbzURDdc0AGJiAsemtT39PAw8WwV7fBOq6A5VQv6.qN6Va.";
27
30
  export async function handleSignIn(event) {
@@ -68,6 +71,19 @@ export async function handleSignIn(event) {
68
71
  });
69
72
  setAccessCookie(event, accessToken, config.accessTtlSeconds);
70
73
  setRefreshCookie(event, refreshToken, config.refreshTtlSeconds);
74
+ const secret = useRuntimeConfig().auth?.invite?.tokenSecret;
75
+ if (input.inviteToken && secret) {
76
+ const verified = verifyInviteToken(input.inviteToken, secret);
77
+ if (verified.ok && normalizeEmail(verified.payload.email) === email) {
78
+ setCookie(event, "or3_invite_token", input.inviteToken, {
79
+ httpOnly: true,
80
+ sameSite: "lax",
81
+ secure: process.env.NODE_ENV === "production",
82
+ path: "/",
83
+ maxAge: Math.min(600, verified.payload.exp - Math.floor(Date.now() / 1e3))
84
+ });
85
+ }
86
+ }
71
87
  return { ok: true };
72
88
  }
73
89
  export default defineEventHandler(async (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "or3-provider-basic-auth",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Saluana/or3-provider-basic-auth.git"