尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A free QR tag, so a kind stranger can message you. Live at https://www.qurtag.com.
An owner makes an account, adds things (a water bottle, a violin case, keys; up to three on the Free
plan) and prints a QR tag for each. Someone who finds the thing scans the tag with their phone camera:
a page opens with no app and no account, shows a finder-safe card for the item, and lets
them send a note. The owner reads it in the inbox at /app and replies; the finder reads the
reply on the same tag page, or on any browser with their conversation code at /reply, and can ask
to be emailed when the owner replies. Neither side sees the other's email or phone number.
The landing page is at /, the app at /app, a tag's page at /find/<tag code>, and a tag's short address at
/<short code> (for example qurtag.com/k7m2p4), which sends the visitor to the same page.
Run by Impact Consulting AI LLC, a Florida limited liability company. Contact: [email protected].
QuR Tag lives in its own schema, qurtag, inside the company's shared Supabase project
(ImpactConsulting, ref nqpumwrtiukrwmhvarst), the same way Apero lives in tentables. It
never uses Supabase Auth (auth.users), Supabase Storage, the public schema or the
project's own data API.
| Part | Where | Database login |
|---|---|---|
| Schema, rules, functions | db/qurtag.sql | applied once as postgres |
| Logins and their passwords | db/logins.sql | applied once as postgres |
| Sign-in (Better Auth, email and password) | api/auth, server/auth/qurtagAuth.ts | qurtag_auth: the auth_* tables only |
| Schema, rules, functions for the Super Admin role | db/admin.sql | applied once as postgres, after the two above |
| Data API for signed-in owners (PostgREST-style) | api/data, server/data/ | qurtag_authenticator, which can only become qurtag_user or qurtag_anon |
| Finder routes | api/fn (finder-view, finder-send, finder-resume, finder-email, finder-email-stop), server/routes/finder.ts, server/routes/finderLoop.ts | qurtag_server: no table rights, only the server_* functions |
| Outbox and scheduled jobs | api/jobs, server/routes/outbox.ts, Vercel cron every 5 minutes (outbox) and daily at 05:00 UTC | qurtag_server |
| Memberships: checkout, portal, family invites, Stripe and RevenueCat webhooks | api/fn, server/routes/billing.ts, server/billing/ | qurtag_server: only the server_* functions of db/membership.sql and db/family.sql |
| iPhone alerts: register a phone, reply from a notification, send to Apple | api/fn (push-register, push-unregister, push-reply), server/routes/push.ts, server/push/apns.ts, the outbox | qurtag_server: only the server_* functions of db/push.sql |
Privacy by structure: a finder never reaches a table. The finder routes call
qurtag.server_finder_view, server_finder_open and server_finder_post, which return only
the item's name, photo, the owner's note for the finder and the lost flag, plus the finder's
own conversation when the browser holds its secret token. The database keeps only the
token's SHA-256. No policy in the schema is open to a visitor without a pass.
Email goes through Resend, called from the server. On the web a finder's note reaches the owner by email, so Settings and the
last step of the first-tag walkthrough say which address it goes to and from, and offer "Send me a test email"
(POST /api/fn/email-test, server/routes/billing.ts: signed in, only to the account's own address, 3 an hour per account
and 10 per network address); the owner's "It arrived" is kept in the browser (src/lib/emailCheck.ts) and Items reminds web
owners who have not confirmed. Password reset ("Forgot password?" at
/forgot-password, the link opens /reset-password) sends its email inside the request, not
through the outbox. The link works once and expires in 1 hour; a reset ends every session of
the account. Requests are limited to 3 per 5 minutes per IP address and 3 per hour per email
address, and the answer is the same whether or not the account exists. Until RESEND_API_KEY
is set, new accounts are confirmed at once, the forgot-password page says "We couldn't send
the email right now, please try again later", and owners read notes in the inbox (the outbox
finishes its rows unsent).
db/admin.sql (rollout step 3) adds the Super Admin role. Who is one is stored in
qurtag.super_admins, written only by a rollout file run by the founder
(4-make-admin.sql), never by the app or any request. The admin screens are at /app/admin
(counts, reported notes, accounts with search, an account's items and tags, switch a tag off, hide a
finder message, delete an account after typing its email) and appear only for a super
admin. Every admin_* function checks in the database, on every call, that the live
session belongs to a super admin, and writes the action to qurtag.admin_audit (who,
what, when; add-only) in the same step. Admins never see finder tokens, sign-in data or
private notes. Tested in scripts/qurtag/e2e.test.mjs.
qurtag)QURTAG_DATABASE_URL, QURTAG_DATABASE_URL_DATA, QURTAG_DATABASE_URL_SERVER (the three
logins on the transaction pooler), QURTAG_DATABASE_CA_CERT, QURTAG_AUTH_SECRET,
QURTAG_AUTH_URL (production: https://www.qurtag.com), CRON_SECRET. Optional: QURTAG_FINDER_SECRET (the key for
finders' conversation codes and emails; when it is not set, QURTAG_AUTH_SECRET is used, which is what production does). Values are kept in
~/Claude/Backups/QurTag/secrets.env on the founder's Mac. Email: RESEND_API_KEY (server side only) and
QURTAG_EMAIL_FROM (default QuR Tag <[email protected]>; the domain must be verified in
Resend). Replies go to [email protected].
What people do is counted in QuR Tag's own database and nowhere else; the Privacy page promises no third-party
analytics and no tracking cookies. db/measures.sql (rollout step 12; undo in db/undo-measures.sql) adds
threads.returned_at (set by a trigger when the owner presses "I have it back"), auth_users.signup_channel (set once at
sign-up, immutable), the tables tag_print_events and app_platforms (row security on, no right for any role the app
uses; written only by record_tag_print and record_app_platform), and extends qurtag.factory_metrics, which the
Company OS reads, with: returned_total, returned_30d; accounts_at_free_limit, accounts_over_free_limit;
tags_printed_total, accounts_that_printed, tags_printed_by_style (and tags_printed_<style>); scans_with_note_30d,
scans_without_note_30d, scans_no_note_rate_30d; signups_by_channel (7d and 30d, per channel: signups, activated);
paid_members_total, paid_<plan>_<source>; iphone_accounts_total, push_enabled_accounts, push_opt_in_rate.
A scan counts as followed by a note when a conversation was opened on the same tag within an hour after it (a scan carries
no visitor id by design). Print events count button presses (Print, a PDF or print-ready download), not copies.
In the app: src/lib/measures.ts keeps ?ch=<channel> (else utm_source) in the tab's sessionStorage (no cookie), sends it
with the sign-up (the iPhone app uses ios-app when it has none), records prints and downloads from the tag page, the several-items page and the first-tag walkthrough, and
records the platform after sign-in. Every call is fire and forget. Deploy order: run rollout step 12 first, then merge: the
new site writes signup_channel at sign-up.
src/lib/errors.ts sends errors only, to the Sentry project cairn, from the website and the iPhone app: no user, cookies,
IP address, request bodies, breadcrumbs, tracing or replay, and addresses and messages are scrubbed of tag, invite and reset
codes. It stays off in automated browsers and on localhost (src/lib/telemetryGate.ts, copied from the Company OS template).
VITE_SENTRY_DSN is set in Vercel (Production). The Vercel build reads it by itself; for an iPhone build, which is built on
this Mac, pull it first: vercel env pull .env.production.local --environment=production, then npm run cap:sync. The
Privacy page names Sentry (section 7).
The rollout that creates the schema and logins in production is
~/Claude/Rollouts/QurTag-rollout-schema (its README lists the steps).
| Plan | Items | People | Scan history | Price (US) |
|---|---|---|---|---|
| Free | 3 | 1 | last 30 days | $0 |
| Plus | unlimited | 1 | all | $19 a year |
| Family | unlimited, shared | up to 5, invited by email | all | $29 a year |
The plans and prices are written once, in server/billing/catalog.ts, which the server and the screens
both import.
One entitlement, two ways to pay. The web pays through Stripe Checkout (with the customer portal to
change or cancel); the iPhone app pays through Apple's in-app purchase, with RevenueCat in between. Both
end up as rows in qurtag.subscriptions, written only by qurtag.server_apply_subscription after a
webhook passed its check. qurtag.household_plan turns the rows and the clock into free, plus or
family; a membership bought either way works on both, and if someone holds two, the better plan counts.
The app never decides a plan: the screens ask my_membership.
POST /api/fn/stripe-webhook: trusted only with a valid Stripe-Signature (HMAC-SHA256
with STRIPE_WEBHOOK_SECRET, 5 minute tolerance, constant time). The plan comes from the price that was
paid. Events: checkout.session.completed, customer.subscription.created, .updated, .deleted.POST /api/fn/revenuecat-webhook: trusted only with Authorization equal to
REVENUECAT_WEBHOOK_AUTH. The account is RevenueCat's app user id, which the app sets to the QuR Tag
account id. Products other than the two yearly ones below are ignored.qurtag.billing_events); a repeat changes nothing, and an event
older than the one already applied to a subscription never overwrites it. A failed write answers 500 so
the sender retries.create_item allows 3 on Free and any number on Plus and Family.
Downgrading never deletes anything. Past the limit the oldest items stay editable and the rest become
read-only (guard_item, guard_tag): they can be read, printed and deleted, but not changed, marked
lost or given a new tag. Finders can still scan their tags and write, and the owner can still reply.
Upgrading, or deleting an item, makes the next oldest editable again.Family (db/family.sql): the owner invites up to 4 people from the Membership page. The invite is a
link (/join/<token>); the database keeps only the token's SHA-256. It works once, for the invited email
address only, and lasts 7 days. If email is not set up, the page shows the link to copy. A member joins the
owner's household, so the existing rules ("rows of a household I belong to") share items, tags, scans and
the inbox, and the finder's note email goes to everyone in the family. A member keeps their own account and
household, which the family never sees. The owner can remove members and a member can leave. When the plan
is no longer Family, members are paused (nothing is deleted) and return if it comes back.
Settings (Vercel, project qurtag, Production; none is set yet, and until they are the pricing page and
the Membership screen say "Coming soon"):
| Setting | What |
|---|---|
STRIPE_SECRET_KEY | Stripe secret key (sk_live_...) |
STRIPE_WEBHOOK_SECRET | the webhook endpoint's signing secret (whsec_...) |
STRIPE_PRICE_PLUS_YEARLY, STRIPE_PRICE_FAMILY_YEARLY | the two yearly recurring prices (price_...) |
REVENUECAT_WEBHOOK_AUTH | a long random string, also pasted into RevenueCat's webhook as the Authorization value |
VITE_REVENUECAT_IOS_API_KEY | RevenueCat's public iOS SDK key (appl_...). Public. A build setting for the iPhone app (.env.local on the building Mac), not a Vercel one |
Optional: QURTAG_BILLING_RATE_PER_ACCOUNT (default 12 a minute) and QURTAG_BILLING_RATE_PER_IP (60).
.env.example lists them all.
Stripe setup. Make one product "QuR Tag Plus" with a yearly price of $19,
and one "QuR Tag Family" with a yearly price of $29 (both recurring, USD). Put the two price ids in the settings
above. Add a webhook endpoint https://www.qurtag.com/api/fn/stripe-webhook for the four events listed
above and put its signing secret in STRIPE_WEBHOOK_SECRET. In the customer portal settings allow
cancelling, changing the payment method and switching between the two prices.
iPhone setup. The two auto-renewable subscriptions are in the group "QuR Tag Membership" in App Store
Connect: app.impactconsultingai.qurtag.plus.yearly and .family.yearly
(Family Sharing off). In RevenueCat: a project with the iOS app (bundle id app.impactconsultingai.qurtag,
connected to App Store Connect with an In-App Purchase key), the two products, one offering holding them,
and a webhook to https://www.qurtag.com/api/fn/revenuecat-webhook with the Authorization value from
REVENUECAT_WEBHOOK_AUTH. Then build the app with the public key (VITE_REVENUECAT_IOS_API_KEY) and
npm run cap:sync. The app signs RevenueCat in with the QuR Tag account id (src/lib/iap.ts). It shows
Apple's own prices, a Restore purchases button, and no way to pay outside Apple.
Until the key is set in the build and RevenueCat returns offerings, the app hides every membership screen, row, prompt and
pricing link (membershipsAvailableInApp() in src/lib/iap.ts, the rule in src/lib/membership-flag.ts); the website is
unchanged, and /join/<token> still works.
Database changes are the rollout files 5 (memberships), 6 (family) and 11 (Free allows 3 items; it changes only household_item_limit, which db/membership.sql already holds with 3) in
~/Claude/Rollouts/QurTag-rollout-schema (copies of db/membership.sql and db/family.sql, each with an
undo). The schema comments in those two files explain every rule.
The iPhone app can alert the owner the moment a finder writes, and the owner can reply from the alert.
Alerts go straight from the server to Apple's push service (APNs) over HTTP/2, signed with a token made
from the company's .p8 key. There is no Firebase and no push vendor.
| Alert | When | Default |
|---|---|---|
| "Someone found your " | a finder sends the first note of a conversation | on |
| "New note about your " | each later note | on |
| "Your tag was scanned" | the tag page was opened and no note followed within 2 minutes; at most one an hour for each tag and person | off |
ios/App/App/App.entitlements).ios/App/App/QurTagNotifications.swift), so it works with the app in the background or closed: it reads
the session token the page kept in the iPhone Keychain (src/lib/nativeSession.ts), asks /api/auth/token
for a short-lived pass, and posts to /api/fn/push-reply. A reply that cannot be sent is announced in a
notification, never lost silently.push-reply.qurtag.push_settings. With the text hidden the
alert says only "You have a new note": no note, no item name.RESEND_API_KEY): a person with a phone who was sent an alert and has not read the note
10 minutes later gets one email per conversation, through the existing Resend path. Opening the alert opens
the conversation, which marks it read. Switch it off in Settings. A person with no phone registered is
emailed at once, as before; a person whose phone Apple refused is emailed at once too.qurtag.push_devices (account, token, sandbox or production, first and last seen).
No role the app uses can read the table and no route returns a token. A token goes when the person signs out,
deletes the account, registers it on another account, or Apple answers 410 or 400 BadDeviceToken for it./api/jobs/outbox at once,
and Vercel Cron runs it every 5 minutes (the email copy is due 10 minutes after a note, a scan alert 2 minutes
after the scan) plus the daily 05:00 UTC run. The finder's request never waits for Apple. A failed try is
repeated with the outbox's backoff and never alerts anyone twice (, and the note's id as
Apple's collapse id).Database changes are rollout step 8 in ~/Claude/Rollouts/QurTag-rollout-schema (8-push.sql, a copy of
db/push.sql with a migration record, and undo-8-push.sql); it needs steps 5 and 6. Run it before merging the
pull request that adds push.
Settings (Vercel, project qurtag, Production). With the first three missing, sending is a no-op that logs
once, and everything else works (people are emailed at once, as before):
| Setting | What |
|---|---|
APNS_KEY_ID | the key's 10-character id (shown in Apple's key list, and in the file name AuthKey_<id>.p8) |
APNS_TEAM_ID | the Apple team id, CQUVU6BHHA |
APNS_KEY_P8 | the whole contents of the .p8 file, including the BEGIN and END lines |
APNS_BUNDLE_ID | optional; default app.impactconsultingai.qurtag |
APNS_ENV | optional; production (default; TestFlight and App Store builds) or sandbox (builds run from Xcode). A phone registers for the Apple server its build uses and a deployment sends only to phones of its own kind |
QURTAG_PUSH_RATE_PER_ACCOUNT (default 30 a minute) and QURTAG_PUSH_RATE_PER_IP (120) limit the push routes.
QURTAG_APNS_URL replaces Apple's address and exists for the local tests only.
Apple setup. One APNs Auth Key (Apple Developer, Certificates, Identifiers & Profiles, Keys) serves every
app of the team, so the company's existing key can be reused. The App ID app.impactconsultingai.qurtag needs
the Push Notifications and Time Sensitive Notifications capabilities (Xcode's automatic signing turns them on
from App.entitlements). aps-environment in the entitlements file stays development; Xcode makes it
production when it signs a TestFlight or App Store build.
A Contact us form in Help, and "Question about a charge?" on the Membership screen. It posts to
/api/fn/support (server/routes/billing.ts), which stores the message in qurtag.support_requests
(topic billing, account, finder or other; open or closed; db/support.sql, rollout step 7). Signed in, the
account, household and plan are attached by the database and the reply email is the account's; signed out, a
reply email is required. The form has no place for card details and says not to send them. Limits: 5
messages an hour per network address (QURTAG_SUPPORT_RATE_PER_IP), 5 an hour per account or email address
and 100 an hour in all (in the database), and a hidden honeypot field a robot fills. No table is reachable
by any request; only server_support_create writes and only the Super Admin functions read.
/app/admin): "Contact requests" lists open ones, oldest first, with Close; both are
written to the admin log.qurtag.factory_metrics also returns support_open, support_30d and support_oldest_open_days, the
names the Company OS support queue reads.RESEND_API_KEY set, a notice goes to [email protected] with reply-to set to the sender.
Without it the message is only stored.A finder is an anonymous stranger who writes to an owner, so the app has the four things Apple asks for.
Database half: db/moderation.sql (rollout step 9, needs steps 3, 7 and 8). Server half: server/moderation/.
| Need | What it is | Where |
|---|---|---|
| Filter | Every note is checked in the finder route before it reaches the database. A slur or a threat (a small hand-kept list in server/moderation/wordlist.ts, no outside service) is accepted and held: the finder sees the normal confirmation, the owner sees "This note may be offensive. Show it?" with Show it and Report, and the text reaches the owner's phone only after Show it (the owner's read policy leaves held notes out; alerts for a held note carry no text). A note that is only links, or has more than 2, is refused with a plain sentence. Limits in the database: 5 notes a minute and 30 an hour per conversation, 60 an hour per tag, 20 new conversations per tag a day, plus the route's 12 notes a minute per network address. An identical note within 10 minutes is accepted but stored once | server/moderation/filter.ts, server/routes/finder.ts, qurtag.server_finder_open and server_finder_post |
| Report | Under each finder note in a conversation: Report, with a reason (harassment, spam, threats, inappropriate, other) and an optional comment. The note is hidden from the owner at once and goes to qurtag.message_reports. After a report the app offers to block the finder | src/routes/app/InboxThread.tsx, qurtag.report_message |
| Respond within 24 hours | Super Admin area (/app/admin), "Reported notes": open reports oldest first, with the note's text, the reason, the owner's comment and the item. Actions: Hide for everyone, Dismiss, Block finder. Each closes the report and is written to the admin log (hide_message, dismiss_report, block_finder) in the same step | src/routes/app/Admin.tsx, qurtag.admin_reports_list, admin_report_resolve |
| Block | "Block this finder" on a conversation (under More). The finder's conversation token can no longer write (a trigger on messages refuses it, whichever function inserts), the conversation and its notes leave the owner's inbox, and the finder gets only "This conversation is closed." For 30 days, new conversations on any tag of the household from the same network (a keyed hash of the address, qurtag.finder_blocks, rollout step 13) are refused with the same words as a paused tag, so a block is not announced. Someone on another network can still write; "Pause new notes on this tag" stops that. Owners see and unblock blocked finders in Settings, under Blocked finders (unblocking lifts the network block); a finder a Super Admin blocked cannot be unblocked by the owner |
qurtag.factory_metrics returns reports_open, reports_30d and
reports_oldest_open_days, and also adds open reports to support_open, support_30d and
support_oldest_open_days, because those three names are all the Factory's support-ticket reader knows.
A report is an item that must be answered within 24 hours, so it belongs in that queue.db/admin-alerts.sql, rollout step 9b, needs step 9): each new report, and each note the filter holds, queues one admin_alert outbox row
(server/routes/outbox.ts). Every super admin with "Admin alerts" on (Settings, shown to super admins only,
on by default; push_settings.admin_on) gets an iPhone push, category QURTAG_ADMIN, time-sensitive: "New
report on QuR Tag. Review it in Admin." (or "N new reports on QuR Tag"), never any note text; a tap opens
/app/admin#reported-notes. With RESEND_API_KEY set, a plain email with a link to /app/admin goes to
[email protected]; without it, no email. At most one alert per 10 minutes (admin_alert_state):
rows queued inside the window wait for it to end and the next alert says how many are waiting, so none is
dropped; a failed send puts the window back and is retried. Tested in scripts/qurtag/push.test.mjs.scripts/qurtag/moderation.test.mjs (database and routes) and scripts/qurtag/unit/moderation.test.ts
(the filter). To change the word list, edit server/moderation/wordlist.ts only.db/ux-fixes.sql (rollout step 13, undo db/undo-ux-fixes.sql), server/routes/finderLoop.ts, server/routes/finderCrypto.ts,
src/routes/finder/FinderLoop.tsx, src/routes/finder/Reply.tsx. The block comments of the SQL file and finderLoop.ts explain
each choice.
qurtag.com/reply (or carried after # in that address) on any browser it opens the conversation. The conversation token is derived
from the code with a keyed hash (QURTAG_FINDER_SECRET, else QURTAG_AUTH_SECRET), so the database still keeps only the token's
SHA-256; wrong codes count under the short code limits. Changing the secret stops old codes working; their links still work.threads.finder_email_enc); no role the app uses
can read the column. Each owner reply queues one finder_reply outbox row; the outbox sends at most one email per conversation per 10
minutes (replies in between go in the same email) with the replies, a link back (/find/<tag>#t=<token>), a spam reminder and a
one-click stop (List-Unsubscribe, /api/fn/finder-email-stop). An owner reply nudges the outbox at once (afterRpc in the data
API). The address is deleted when the conversation is closed or the finder is blocked, right after the last email when the owner
marks it returned, on stop, and after 30 days without a note (the daily job, server_finder_sweep). The field is shown only when
email is set up.set_item_notes_paused; existing conversations go on), Close to this finder and Block are under
More. A held note held for a threat shows safety advice. The inbox previews a held note without its words, labels several finders of
one item Finder 1, Finder 2, shows who wrote a reply in a Family ("Sam:", messages.sender_user_id), and keeps archived conversations
under Archived (a new note brings one back).Styles (tag page, src/routes/app/TagPrint.tsx): six styles, each in three looks (Clean, Bold, Playful), on US
Letter or A4 with several copies a page.
| Style | One piece | Copies on Letter / A4 | Notes |
|---|---|---|---|
| Small sticker | 1.25 x 1.25 in | 35 / 35 | cut marks; bold "LOST? SCAN ME" line above the code (1 in leaves no room for a line a person can read), the QuR mark beside the code and the short address under it, horizontal, at about 8 pt |
| Label strip | 2.625 x 1 in | 30 / 18 | on Letter the standard 30 per sheet address label sheet (3 across, 10 down), no marks |
| Key fob insert | 1 x 1.4 in, two sides | 18 / 18 | front "IF FOUND, SCAN ME" and QR, back short code and address; fold line; the back is printed upside down so it reads upright once folded |
| Wallet card | 3.375 x 2.125 in | 8 / 8 | cut marks |
| Bag tag | 2.4 x 3.5 in folded | 3 / 3 | "If found" large; fold line |
| Clothing strip | 2.5 x 1 in | 16 / 18 | "IF FOUND, SCAN ME" in large capitals; iron-on transfer paper; "Print mirrored" flips the whole page |
The page prints a few copies unless the person chooses more (defaultCopies in src/lib/tagLayout.ts: 4 small stickers,
3 label strips, 2 key fob inserts, 2 wallet cards, 1 bag tag, 4 clothing strips), so the rest of a sheet is left for the
next thing. "Print several items on one page" (/app/tags/sheet, src/routes/app/TagSheet.tsx, linked from Tags and from
each tag page) puts the tags of several items on one sheet in one style, with the number of copies of each; the top margin
of every sheet names each item with its short address (sheetLabel), which is also printed on each piece. A tag's page
opens on the style the tag was last printed in or, for a tag never printed, the style its item's name suits
(src/lib/printPrefs.ts, kept on the device); the look and paper are remembered for all tags. The item's name is printed on
a tag ("On: ..." on the wallet card and the bag tag) only when the owner ticks "Print the item's name on the tag"; it is
off by default because a name on a child's bag tells any stranger the child's name. Each style says what it needs (a
clear holder for the key fob, wallet card and bag tag) and a way to do without.
On the web, Print and Save the PDF are two equal choices (src/components/print/PrintChoices.tsx), with a line for people
without a printer (a library or a print shop). Inside another app's browser (Facebook, Instagram, Messenger, TikTok,
LinkedIn and others, src/lib/inAppBrowser.ts) the screen first says to open the page in Safari or Chrome, with Copy the
link and, on Android, Open in Chrome.
Every style has a thin orange QuR Tag frame (with a black keyline on the yellow Playful plate). Heavy lines are set in
Helvetica-Bold; the PDF names that face, but pdftoppm without a Helvetica Bold substitute draws it regular, so check
weight in the SVG previews or a Quartz/Chrome render.
The sizes, grids and the QR rules are in src/lib/tagLayout.ts. The QR code is always error correction level H,
printed at least 0.75 in (MIN_QR_IN; qrSize throws below it) with a quiet zone of at least 2 modules, black on white
in every look. A tag is a list of shapes (src/lib/tagScene.ts) drawn as SVG (the preview and the PNG) and as vector PDF
(src/lib/tagPdf.ts, Helvetica base fonts, no font file). npx tsx scripts/qurtag/render-tags.mjs <folder> writes every
style and look as PDF and (with poppler) PNG; the unit tests decode the QR code of each at 300 dpi.
Print-ready file: one piece at exact size with 0.125 in bleed on every side, as a 600 dpi PNG (resolution stamped in the file) and a PDF with trim and bleed boxes, for any online sticker printer. No vendor is named or linked.
Short codes (db/shortcodes.sql, rollout step 10): every tag has short_code, 6 characters (5 are allowed) from
abcdefghjkmnpqrstuvwxyz23456789 (no 0, o, 1, l, i), at least one digit, lower case, unique. A trigger gives each new tag
its code. /<code> (any case) is rewritten by vercel.json to /api/fn?go=<code> (server/routes/shortcode.ts) and
answers 302 to /find/<tag>; an unknown, replaced or switched-off code answers an honest 404 page. The rewrite matches only
5 or 6 characters that hold a digit, so it never catches a page of the site. Limits: 30 lookups a minute per network address
and instance, and in the database 20 wrong guesses per visitor in 10 minutes and 600 in a minute in all, counted under a
keyed hash of the address. The QR code still holds the full /find/ address, so a printed tag never depends on the lookup.
Make it last (/make-it-last, src/routes/marketing/MakeItLast.tsx): plain, brand-neutral advice on paper, covers,
holders and clothing. It names no brand and links to no shop; the same page opens inside the iPhone app. The app does not
sell tags.
src/lib/features.ts hides code built for a bigger plan that cannot work yet: cash rewards,
courier labels, trips, partner drop-off points, insurance packets, wallet passes, location
sharing and web push (the browser kind; the iPhone app's alerts are described above). Their tables are not in
schema qurtag. VITE_QURTAG_LABS=1 shows them for local work only. The old Supabase
migrations and Edge Functions for them are kept in supabase/ for reference; they are not
deployed.
npm install
npm run typecheck # app, Vite config and server code
npm run test:unit # the finder routes, the payment code, the push code, the tag layout maths and drawn tags (QR codes decoded back), the short code route, with stand-ins
npm run test:e2e # the whole loop, memberships, webhooks, Family, push alerts (with a stand-in Apple server), short codes, moderation, measures and the finder loop (codes, the finder's email, blocks, pause), on a throwaway local Postgres (needs Docker)
npm run build
Large text. scripts/qurtag/large-text.check.mjs opens the signed-in screens, sign-in, Help, Terms and the finder's page in
WebKit at 375, 402 and 430 px wide, at the root font sizes 17, 23, 28, 33 and 53 px (the iPhone's default, its largest standard
text size and Accessibility 1, 2 and 5), and fails on sideways scrolling, a word broken in the middle, overlapping controls, a
button smaller than 44 by 44 px, a cut-off heading, or an Inbox tab not named "Inbox, N unread". It needs a running app on a
throwaway database (below) and any copy of Playwright with WebKit, which the app does not depend on:
QURTAG_TEST_BASE=http://localhost:5173 NODE_PATH=<folder holding playwright> node scripts/qurtag/large-text.check.mjs
(QURTAG_SHOTS=<folder> keeps a screenshot of each case).
To try the app by hand: npm run dev:local (a local database with the schema, plus the
server functions on 127.0.0.1:56861), then in another Terminal
QURTAG_API_PROXY=http://127.0.0.1:56861 npm run dev and open http://localhost:5173.
The logo (the "QuR" mark, the lockup with the tagline "Stick it on. Get it back.") is drawn once
in scripts/brand/build-brand.mjs; npm run brand writes the iPhone app icon and splash, the
favicons, the web app icons, public/brand/*.svg, the share image public/og.png and the paths the
Wordmark component draws (src/components/brand/logoPaths.ts).
Stack: React 18, TypeScript, Vite, Tailwind, React Router 7, Better Auth 1.7.7, pg,
postgrest-js. QR codes are made in the browser (qrcode.react) and drawn by src/lib/tagStyles.ts; the tag page
prints a vector page of tags, or saves it as a PDF. Colours are CSS variables (src/index.css)
with a dark set under .dark, so every page follows the theme without per-page dark rules.
The iPhone app (bundle id app.impactconsultingai.qurtag) is this web app in a Capacitor
shell (capacitor.config.ts, ios/). The old Expo stub in mobile/ is not used.
npm run cap:sync builds the web app and copies it into ios/; npm run cap:ios also opens Xcode.capacitor://localhost, so it calls the live site by full
address (src/lib/platform.ts). The server answers that origin with CORS including
credentials (server/auth/phoneCors.ts, the data API) and Better Auth trusts it
(server/auth/origins.ts). The session is a bearer token kept on the phone (Better Auth's
bearer plugin), never a cookie across origins./app. Links to other sites open in Safari's sheet, which has a Done button back to the app.Info.plist carries
NSCameraUsageDescription and NSPhotoLibraryUsageDescription; iOS asks the first time either is
used. The app requests no location or NFC permission, and asks for notifications once, as described under "iPhone push notifications". The tag page's
"Print or share" makes the chosen page of tags on the phone as a vector PDF (src/lib/tagPdf.ts) and opens the
iOS share sheet through @capacitor/share and @capacitor/filesystem (src/lib/saveFile.ts), which offers Print,
Save to Files and AirDrop. On the web it opens the browser's print dialog, or downloads the PDF.src/components/layout/MarketingLayout.tsx).src/components/layout/AppLayout.tsx). Each
screen has a title, and nested screens a Back button (src/components/layout/AppPage.tsx).
Help, Terms, Privacy and Make it last open inside that frame for signed-in people, at /app/help, /app/terms,
/app/privacy and /app/make-it-last (InAppDoc in src/routes/marketing/parts.tsx); the public pages stay for visitors.
A new account, and any account with no items, opens the first-tag walkthrough at /app/first-tag
(src/routes/app/FirstTag.tsx; skippable, resumes from Items; style chosen by src/lib/tagRecommend.ts). Print (or Save the
PDF) is the main action of the print step, with "I have printed it" after it. The test step asks the owner to scan the printed
tag with the phone camera and say whether the item's page opened; the finder page's test mode (/find/<code>#test=1: no scan
recorded, nothing sent) is the fallback for someone with no tag yet, worded as "the page a finder sees". A visit by someone signed in on that browser to a tag of their own household is not recorded as a scan either
(the page asks the data API, under row security, whether the tag is theirs; in ), so an
owner's own camera scan stays out of the scan history and the measures when their phone's browser is signed in. The last step is the
iPhone's alerts in the app and, on the web when email is on, the email check below. Items shows a Getting started card (add,
print, stick on, scan the printed tag; alerts in the app) until its steps are done or skipped; "I stuck it on" and "It opened"
are buttons with a separate Skip, and opening the test view does not tick the scan step. The card is hidden for someone who
joined a family. Progress is kept per account in the browser or phone (), as the database has
no preferences table; so is each tag's printed and stuck-on state (), which Items shows as the item's
status ("Tag ready to print", "Printed, not stuck on yet", "Tag is on"). The item name box says "Describe the thing, not the
person", and the name and the word for the finder warn, without blocking, when they look like they hold a name, a phone
number, an email or a street address (). Add item checks the plan's limit when it opens
(). Help at is grouped by task with search (), and each main screen has a
"?" in its top bar that opens the matching group. Each top-level screen has a phone top bar (the QuR Tag brand at the left; Add item on Items, Help and the account
avatar at the right) over a large title that collapses into it (); the account card in Settings opens (name, password, data, deleting, signing out).
The How it works pictures are in (tags drawn by , written by ; two real screenshots; light and dark).qurtag.note_alertssrc/components/moderation/BlockedFinders.tsx, qurtag.block_finder, unblock_finder, my_blocked_finders |
| Published contact | /terms section 9 (zero tolerance, how to report and block, reports reviewed within 24 hours), the Help answer "Report or block a finder", and a "Report a problem with this page" link on the finder's page. Contact stays [email protected] | src/routes/marketing/Terms.tsx, Help.tsx, src/routes/finder/FinderView.tsx |
scripts/qurtag/finder-loop.test.mjs (e2e, on the copy of the server that captures email) and
scripts/qurtag/unit/finder-loop.test.ts.shouldRecordScansrc/lib/finder.tssrc/lib/gettingStarted.tssrc/lib/tagProgress.tssrc/lib/personalDetails.tssrc/lib/itemLimit.ts/app/helpsrc/lib/helpContent.tsAppPage/app/accountpublic/howsrc/lib/tagStyles.tsnpx tsx scripts/qurtag/how-tags.mjssrc/index.css, and every text size is in rem). From the largest standard size up, src/lib/textSize.ts marks the page
text-ax (and text-axx from 40 px), and the ax: and axx: Tailwind variants change layouts rather than only sizes: rows with
a picture put the text under it, decorative icon chips are left out, switches drop under their text, grids become one column,
bars put the title on its own line, button labels may wrap, and side padding, gaps and corner radii keep their standard size in
px. Pictures, icons and switches are sized in px. Words wrap whole (overflow-wrap: break-word).src/components/push/PushPrimer.tsx), never at launch. See "iPhone push notifications".MARKETING_VERSION and CURRENT_PROJECT_VERSION in
ios/App/App.xcodeproj/project.pbxproj.