s3nd

Configuring codes

Length, alphabet, and what each choice costs — including what happens to codes already out there when you change your mind.

A sync code has two knobs, and both live on the store so that generation and normalization can never disagree:

import { createBucket, syncCodeAlphabets } from 's3nd'

const store = createBucket({
  bucket: 'my-bucket',
  syncCode: {
    length: 4,
    alphabet: syncCodeAlphabets.digits,
  },
})

store.codes.create() // "8143"
OptionDefault
length8Number of characters, from 1 to 64.
alphabetsyncCodeAlphabets.crockfordAt least two distinct characters. No spaces, dashes or underscores — those are stripped when a typed code is read back, so a code could never contain one.

The alphabets that ship

syncCodeAlphabetsCharactersBits each
crockford0123456789ABCDEFGHJKMNPQRSTVWXYZ5
digits01234567893.32
alphanumericABCDEFGHIJKLMNOPQRSTUVWXYZ01234567895.17

crockford is the default because it is the one designed for this exact problem — a value a human reads off one screen and types into another. It drops I, L, O and U: the first three are what people misread, and losing U keeps a random code from spelling something unfortunate.

alphanumeric is there for when you need the extra bit per character and control the display well enough that O versus 0 will not bite you. It is the wrong default and a fine deliberate choice.

Choosing a shape

The question is never "how long should a code be" on its own. It is how long a code lives, and how many guesses per second your lookup route allows:

ShapeBitsExpected guesses at 10/sat 1000/s
4 digits13.38 minutes5 seconds
6 digits19.914 hours8 minutes
6 Crockford302 years6 days
8 Crockford (default)402000 years17 years
12 Crockford60beyond meaningbeyond meaning

Read that table with the code's lifetime next to it. A four-digit code is guessable in eight minutes at ten attempts per second — which is fine if it expires in five and your route is rate limited, and catastrophic if it lives for a day behind an open endpoint.

// A four-digit code someone reads over the phone.
const store = createBucket({
  bucket: 'my-bucket',
  syncCode: { length: 4, alphabet: syncCodeAlphabets.digits },
})

await store.putSnapshot(code, state, {
  expiresIn: 5 * 60, // minutes, not hours
  ifAbsent: true,
})

The package will tell you what you picked, so you can assert on it or log it at boot:

store.codes.entropyBits // 13.29

Short codes are a real product choice — reading eight characters aloud is miserable. They are only safe with a short expiry and a rate limit on the lookup. Without the rate limit, a four-digit code is not a code, it is a public URL.

Custom alphabets

Any string works, subject to two rules: at least two distinct characters, and no spaces, dashes or underscores.

createBucket({
  bucket: 'my-bucket',
  syncCode: { length: 6, alphabet: 'ABCDEFGHJKMNPQRSTUVWXYZ' }, // letters only, no I, L or O
})

What normalize() does is derived from the alphabet, not hardcoded — which is why the two halves belong on one object:

When the alphabet…normalize()
alwaysstrips spaces, dashes and underscores
has a single casefolds case, so k7qp finds K7QP
has both caseskeeps case as typed — folding it would break the lookup
has 0 and no Oreads a typed O as zero
has 1 and no Ireads a typed I as one
has 1 and no Lreads a typed L as one
has both 0 and Oleaves both alone, because there is no way to know which was meant

So a digits alphabet gets the confusable repairs for free, and alphanumeric gets none of them. That is the honest behaviour: a repair is only safe when the alphabet makes it unambiguous.

normalize() validates characters, not length. A code of the wrong length made of valid characters comes back unchanged and simply finds nothing — which is what you want, since a lookup miss and an expired code should look the same to the person typing.

Changing the shape later

Codes already issued do not migrate. Under a new alphabet, an old code containing a character the new one lacks throws INVALID_SYNC_CODE; under a new length, it normalizes fine and finds nothing.

For transfer codes this is a non-event, and that is the argument for keeping expiresIn short: change the shape, and the only codes affected are the ones issued in the last hour. Ship it and move on.

If you need a real transition — long-lived codes, or a shape change you cannot schedule — keep the old scheme around for reads:

import { createSyncCodes, syncCodeAlphabets } from 's3nd'

const legacy = createSyncCodes({ length: 8, alphabet: syncCodeAlphabets.crockford })

function normalizeEither(typed: string): string {
  try {
    return store.codes.normalize(typed)
  } catch {
    return legacy.normalize(typed) // throws in turn if it is neither
  }
}

Read through both, write only through the new one, and drop the fallback once the old codes have expired.

Outside a store

createSyncCodes() builds a scheme on its own — useful in tests, scripts, or anywhere you have no store at hand:

import { createSyncCode, createSyncCodes, normalizeSyncCode } from 's3nd'

const codes = createSyncCodes({ length: 6 })

createSyncCode() // the default scheme
normalizeSyncCode('k7qp2m4x') // the default scheme

Prefer store.codes in application code. Configuring the shape in one place is the whole point: two call sites that each build their own scheme are two call sites that can disagree.

On this page